diff --git a/openwebrx/.dockerignore b/openwebrx/.dockerignore new file mode 100644 index 0000000..8c815de --- /dev/null +++ b/openwebrx/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +.idea +**/*.pyc +**/*.swp +black-env +debian \ No newline at end of file diff --git a/openwebrx/CHANGELOG.md b/openwebrx/CHANGELOG.md new file mode 100644 index 0000000..4635f83 --- /dev/null +++ b/openwebrx/CHANGELOG.md @@ -0,0 +1,204 @@ +**1.1.0** +- Reworked most graphical elements as SVGs for faster loadtimes and crispier display on hi-dpi displays +- Updated pipelines to match changes in digiham +- Changed D-Star and NXDN integrations to use new decoders from digiham +- Added D-Star and NXDN metadata display + +**1.0.0** +- Introduced `squelch_auto_margin` config option that allows configuring the auto squelch level +- Removed `port` configuration option; `rtltcp_compat` takes the port number with the new connectors +- Added support for new WSJT-X modes FST4, FST4W (only available with WSJT-X 2.3) and Q65 (only avilable with + WSJT-X 2.4) +- Added support for demodulating M17 digital voice signals using m17-cxx-demod +- New reporting infrastructure, allowing WSPR and FST4W spots to be sent to wsprnet.org +- Add some basic filtering capabilities to the map +- New arguments to the `openwebrx` command-line to facilitate the administration of users (try `openwebrx admin`) +- Default bandwidth changes: + - "WFM" changed to 150kHz + - "Packet" (APRS) changed to 12.5kHz +- Configuration rework: + - New: fully web-based configuration interface + - System configuration parameters have been moved to a new, separate `openwebrx.conf` file + - Remaining parameters are now editable in the web configuration + - Existing `config_webrx.py` files will still be read, but changes made in the web configuration will be written to + a new storage system + - Added upload of avatar and panorama image via web configuration +- New devices supported: + - HPSDR devices (Hermes Lite 2) thanks to @jancona + - BBRF103 / RX666 / RX888 devices supported by libsddc + - R&S devices using the EB200 or Ammos protocols + +**0.20.3** +- Fix a compatibility issue with python versions <= 3.6 + +**0.20.2** +- Fix a security problem that allowed arbitrary commands to be executed on the receiver + ([See github issue #215](https://github.com/jketterl/openwebrx/issues/215)) + +**0.20.1** +- Remove broken OSM map fallback + +**0.20.0** +- Added the ability to sign multiple keys in a single request, thus enabling multiple users to claim a single receiver + on receiverbook.de +- Fixed file descriptor leaks to prevent "too many open files" errors +- Add new demodulator chain for FreeDV +- Added new HD audio streaming mode along with a new WFM demodulator +- Reworked AGC code for better results in AM, SSB and digital modes +- Added support for demodulation of "Digital Radio Mondiale" (DRM) broadcast using the "dream" decoder. +- New default waterfall color scheme +- Prototype of a continuous automatic waterfall calibration mode +- New devices supported: + - FunCube Dongle Pro+ (`"type": "fcdpp"`) + - Support for connections to rtl_tcp (`"type": "rtl_tcp"`) + +**0.19.1** +- Added ability to authenticate receivers with listing sites using "receiver id" tokens + +**0.19.0** +- Fix direwolf connection setup by implementing a retry loop +- Pass direct sampling mode changes for rtl_sdr_soapy to owrx_connector +- OSM maps instead of Google when google_maps_api_key is not set (thanks @jquagga) +- Improved logic to pass parameters to soapy devices. + - `rtl_sdr_soapy`: added support for `bias_tee` + - `sdrplay`: added support for `bias_tee`, `rf_notch` and `dab_notch` + - `airspy`: added support for `bitpack` +- Added support for Perseus-SDR devices, (thanks @amontefusco) +- Property System has been rewritten so that defaults on sdr behave as expected +- Waterfall range auto-adjustment now only takes the center 80% of the spectrum into account, which should work better + with SDRs that oversample or have rather flat filter curves towards the spectrum edges +- Bugfix for negative network usage +- FiFi SDR: prevent arecord from shutting down after 2GB of data has been sent +- Added support for bias tee control on rtl_sdr devices +- All connector driven SDRs now support `"rf_gain": "auto"` to enable AGC +- `rtl_sdr` type now also supports the `direct_sampling` option +- Added decoding implementation for for digimode "JS8Call" + (requires an installation of [js8call](http://js8call.com/) and + [the js8py library](https://github.com/jketterl/js8py)) +- Reorganization of the frontend demodulator code +- Improve receiver load time by concatenating javascript assets +- Docker images migrated to Debian slim images; This was necessary to allow the use of function multiversioning in + csdr and owrx_connector to allow the images to run on a wider range of CPUs +- Docker containers have been updated to include the SDRplay driver version 3 +- HackRF support is now based on SoapyHackRF +- Removed sdr.hu server listing support since the site has been shut down +- Added support for Radioberry 2 Rasbperry Pi SDR Cape + +**0.18.0** +- Support for SoapyRemote + +**2020-02-08** +- Compression, resampling and filtering in the frontend have been rewritten in javascript, sdr.js has been removed +- Decoding of Pocsag modulation is now possible +- Removed the 3D waterfall since it had no real application and required ~1MB of javascript code to be downloaded +- Improved the frontend handling of the "too many users" scenario +- PSK63 digimode is now available (same decoding pipeline as PSK31, but with adopted parameters) +- The frequency can now be manipulated with the mousewheel, which should allow the user to tune more precise. The tuning + step size is determined by the digit the mouse cursor is hovering over. +- Clicking on the frequency now opens an input for direct frequency selection +- URL hashes have been fixed and improved: They are now updated automatically, so a shared URL will include frequency + and demodulator, which allows for improved sharing and linking. +- New daylight scheduler for background decoding, allows profiles to be selected by local sunrise / sunset times +- New devices supported: + - LimeSDR (`"type": "lime_sdr"`) + - PlutoSDR (`"type": "pluto_sdr"`) + - RTL_SDR via Soapy (`"type": "rtl_sdr_soapy"`) on special request to allow use of the direct sampling mode + +**2020-01-04** +- The [owrx_connector](https://github.com/jketterl/owrx_connector) is now the default way of communicating with sdr + devices. The old sdr types have been replaced, all `_connector` suffixes on the type must be removed! +- The sources have been refactored, making it a lot easier to add support for other devices +- SDR device failure handling has been improved, including user feedback +- New devices supported: + - FiFiSDR (`"type": "fifi_sdr"`) + +**2019-12-15** +- wsjt-x updated to 2.1.2 +- The rtl_tcp compatibility mode of the owrx_connector is now configurable using the `rtltcp_compat` flag + +**2019-12-10** +- added support for airspyhf devices (Airspy HF+ / Discovery) + +**2019-12-05** +- explicit device filter for soapy devices for multi-device setups + +**2019-12-03** +- compatibility fixes for safari browsers (ios and mac) + +**2019-11-24** +- There is now a new way to interface with SDR hardware, . + They talk directly to the hardware (no rtl_sdr / rx_sdr necessary) and offer I/Q data on a socket, just like nmux + did before. They additionally offer a control socket that allows openwebrx to control the SDR parameters directly, + without the need for repeated restarts. This allows for quicker profile changes, and also reduces the risk of your + SDR hardware from failing during the switchover. See `config_webrx.py` for further information and instructions. +- Offset tuning using the `lfo_offset` has been reworked in a way that `center_freq` has to be set to the frequency you + actually want to listen to. If you're using an `lfo_offset` already, you will probably need to change its sign. +- `initial_squelch_level` can now be set on each profile. +- As usual, plenty of fixes and improvements. + +**2019-10-27** +- Part of the frontend code has been reworked + - Audio buffer minimums have been completely stripped. As a result, you should get better latency. Unfortunately, + this also means there will be some skipping when audio starts. + - Now also supports AudioWorklets (for those browser that have it). The Raspberry Pi image has been updated to include + https due to the SecureContext requirement. + - Mousewheel controls for the receiver sliders +- Error handling for failed SDR devices + +**2019-09-29** +- One of the most-requested features is finally coming to OpenWebRX: Bookmarks (sometimes also referred to as labels). + There's two kinds of bookmarks available: + - Serverside bookmarks that are set up by the receiver administrator. Check the file `bookmarks.json` for examples! + - Clientside bookmarks which every user can store for themselves. They are stored in the browser's localStorage. +- Some more bugs in the websocket handling have been fixed. + +**2019-09-25** +- Automatic reporting of spots to [pskreporter](https://pskreporter.info/) is now possible. Please have a look at the + configuration on how to set it up. +- Websocket communication has been overhauled in large parts. It should now be more reliable, and failing connections + should now have no impact on other users. +- Profile scheduling allows to set up band-hopping if you are running background services. +- APRS now has the ability to show symbols on the map, if a corresponding symbol set has been installed. Check the + config! +- Debug logging has been disabled in a handful of modules, expect vastly reduced output on the shell. + +**2019-09-13** +- New set of APRS-related features + - Decode Packet transmissions using [direwolf](https://github.com/wb2osz/direwolf) (1k2 only for now) + - APRS packets are mostly decoded and shown both in a new panel and on the map + - APRS is also available as a background service + - direwolfs I-gate functionality can be enabled, which allows your receiver to work as a receive-only I-gate for the + APRS network in the background +- Demodulation for background services has been optimized to use less total bandwidth, saving CPU +- More metrics have been added; they can be used together with collectd and its curl_json plugin for now, with some + limitations. + +**2019-07-21** +- Latest Features: + - More WSJT-X modes have been added, including the new FT4 mode + - I started adding a bandplan feature, the first thing visible is the "dial" indicator that brings you right to the + dial frequency for digital modes + - fixed some bugs in the websocket communication which broke the map + +**2019-07-13** +- Latest Features: + - FT8 Integration (using wsjt-x demodulators) + - New Map Feature that shows both decoded grid squares from FT8 and Locations decoded from YSF digital voice + - New Feature report that will show what functionality is available +- There's a new Raspbian SD Card image available (see below) + +**2019-06-30** +- I have done some major rework on the openwebrx core, and I am planning to continue adding more features in the near + future. Please check this place for updates. +- My work has not been accepted into the upstream repository, so you will need to chose between my fork and the official + version. +- I have enabled the issue tracker on this project, so feel free to file bugs or suggest enhancements there! +- This version sports the following new and amazing features: + - Support of multiple SDR devices simultaneously + - Support for multiple profiles per SDR that allow the user to listen to different frequencies + - Support for digital voice decoding + - Feature detection that will disable functionality when dependencies are not available (if you're missing the digital + buttons, this is probably why) +- Raspbian SD Card Images and Docker builds available (see below) +- I am currently working on the feature set for a stable release, but you are more than welcome to test development + versions! diff --git a/openwebrx/LICENSE.txt b/openwebrx/LICENSE.txt new file mode 100644 index 0000000..dba13ed --- /dev/null +++ b/openwebrx/LICENSE.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/openwebrx/README.md b/openwebrx/README.md new file mode 100644 index 0000000..9517148 --- /dev/null +++ b/openwebrx/README.md @@ -0,0 +1,60 @@ +OpenWebRX +========= + +OpenWebRX is a multi-user SDR receiver software with a web interface. + +![OpenWebRX](https://www.openwebrx.de/gfx/openwebrx-screenshot.png) + +It has the following features: + +- [csdr](https://github.com/jketterl/csdr) based demodulators (AM/FM/SSB/CW/BPSK31/BPSK63) +- filter passband can be set from GUI +- it extensively uses HTML5 features like WebSocket, Web Audio API, and Canvas +- it works in Google Chrome, Chromium and Mozilla Firefox +- supports a wide range of [SDR hardware](https://github.com/jketterl/openwebrx/wiki/Supported-Hardware#sdr-devices) +- Multiple SDR devices can be used simultaneously +- [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF, Pocsag, D-Star, NXDN) +- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) based demodulators (FT8, FT4, WSPR, JT65, JT9, FST4, + FST4W) +- [direwolf](https://github.com/wb2osz/direwolf) based demodulation of APRS packets +- [JS8Call](http://js8call.com/) support +- [DRM](https://github.com/jketterl/openwebrx/wiki/DRM-demodulator-notes) support +- [FreeDV](https://github.com/jketterl/openwebrx/wiki/FreeDV-demodulator-notes) support +- M17 support based on [m17-cxx-demod](https://github.com/mobilinkd/m17-cxx-demod) + +## Setup + +The following methods of setting up a receiver are currently available: + +- Raspberry Pi SD card images +- Debian repository +- Docker images +- Manual installation + +Please checkout the [setup guide on the wiki](https://github.com/jketterl/openwebrx/wiki/Setup-Guide) for more details +on the respective methods. + +## Community + +If you have trouble setting up or configuring your receiver, you have some great idea you want to see implemented, or +you just generally want to have some OpenWebRX-related chat, come visit us over on +[our groups.io group](https://groups.io/g/openwebrx). + +If you want to hang out, chat, or get in touch directly with the developers, receiver operators or users, feel free to +drop by in [our Discord server](https://discord.gg/gnE9hPz). + +## Usage tips + +You can zoom the waterfall display by the mouse wheel. You can also drag the waterfall to pan across it. + +The filter envelope can be dragged at its ends and moved around to set the passband. + +However, if you hold down the shift key, you can drag the center line (BFO) or the whole passband (PBS). + +## Licensing + +OpenWebRX is available under Affero GPL v3 license +([summary](https://tldrlegal.com/license/gnu-affero-general-public-license-v3-(agpl-3.0))). + +OpenWebRX is also available under a commercial license on request. Please contact me at the address +*<randras@sdr.hu>* for licensing options. diff --git a/openwebrx/bands.json b/openwebrx/bands.json new file mode 100644 index 0000000..02bb703 --- /dev/null +++ b/openwebrx/bands.json @@ -0,0 +1,367 @@ +[ + { + "name": "2190m", + "lower_bound": 135700, + "upper_bound": 137800, + "frequencies": { + "fst4": 136000, + "fst4w": 136000 + }, + "tags": ["hamradio"] + }, + { + "name": "630m", + "lower_bound": 472000, + "upper_bound": 479000, + "frequencies": { + "fst4": 474200, + "fst4w": 474200 + }, + "tags": ["hamradio"] + }, + { + "name": "160m", + "lower_bound": 1810000, + "upper_bound": 2000000, + "frequencies": { + "bpsk31": 1838000, + "ft8": 1840000, + "wspr": 1836600, + "jt65": 1838000, + "jt9": 1839000, + "js8": 1842000, + "fst4": 1839000, + "fst4w": 1836800 + }, + "tags": ["hamradio"] + }, + { + "name": "80m", + "lower_bound": 3500000, + "upper_bound": 3800000, + "frequencies": { + "bpsk31": 3580000, + "ft8": 3573000, + "wspr": 3592600, + "jt65": 3570000, + "jt9": 3572000, + "ft4": [3568000, 3575000], + "js8": 3578000 + }, + "tags": ["hamradio"] + }, + { + "name": "60m", + "lower_bound": 5351500, + "upper_bound": 5366500, + "frequencies": { + "ft8": 5357000, + "wspr": 5364700 + }, + "tags": ["hamradio"] + }, + { + "name": "40m", + "lower_bound": 7000000, + "upper_bound": 7200000, + "frequencies": { + "bpsk31": 7040000, + "ft8": 7074000, + "wspr": 7038600, + "jt65": 7076000, + "jt9": 7078000, + "ft4": 7047500, + "js8": 7078000 + }, + "tags": ["hamradio"] + }, + { + "name": "30m", + "lower_bound": 10100000, + "upper_bound": 10150000, + "frequencies": { + "bpsk31": 10141000, + "ft8": 10136000, + "wspr": 10138700, + "jt65": 10138000, + "jt9": 10140000, + "ft4": 10140000, + "js8": 10130000 + }, + "tags": ["hamradio"] + }, + { + "name": "20m", + "lower_bound": 14000000, + "upper_bound": 14350000, + "frequencies": { + "bpsk31": 14070000, + "ft8": 14074000, + "wspr": 14095600, + "jt65": 14076000, + "jt9": 14078000, + "ft4": 14080000, + "js8": 14078000 + }, + "tags": ["hamradio"] + }, + { + "name": "17m", + "lower_bound": 18068000, + "upper_bound": 18168000, + "frequencies": { + "bpsk31": 18098000, + "ft8": 18100000, + "wspr": 18104600, + "jt65": 18102000, + "jt9": 18104000, + "ft4": 18104000, + "js8": 18104000 + }, + "tags": ["hamradio"] + }, + { + "name": "15m", + "lower_bound": 21000000, + "upper_bound": 21450000, + "frequencies": { + "bpsk31": 21070000, + "ft8": 21074000, + "wspr": 21094600, + "jt65": 21076000, + "jt9": 21078000, + "ft4": 21140000, + "js8": 21078000 + }, + "tags": ["hamradio"] + }, + { + "name": "12m", + "lower_bound": 24890000, + "upper_bound": 24990000, + "frequencies": { + "bpsk31": 24920000, + "ft8": 24915000, + "wspr": 24924600, + "jt65": 24917000, + "jt9": 24919000, + "ft4": 24919000, + "js8": 24922000 + }, + "tags": ["hamradio"] + }, + { + "name": "10m", + "lower_bound": 28000000, + "upper_bound": 29700000, + "frequencies": { + "bpsk31": [28070000, 28120000], + "ft8": 28074000, + "wspr": 28124600, + "jt65": 28076000, + "jt9": 28078000, + "ft4": 28180000, + "js8": 28078000 + }, + "tags": ["hamradio"] + }, + { + "name": "6m", + "lower_bound": 50030000, + "upper_bound": 51000000, + "frequencies": { + "bpsk31": 50305000, + "ft8": 50313000, + "wspr": 50293000, + "jt65": 50310000, + "jt9": 50312000, + "ft4": 50318000, + "js8": 50318000, + "q65": [50211000, 50275000] + }, + "tags": ["hamradio"] + }, + { + "name": "4m", + "lower_bound": 70150000, + "upper_bound": 70200000, + "frequencies": { + "wspr": 70091000 + }, + "tags": ["hamradio"] + }, + { + "name": "2m", + "lower_bound": 144000000, + "upper_bound": 146000000, + "frequencies": { + "wspr": 144489000, + "ft8": 144174000, + "ft4": 144170000, + "jt65": 144120000, + "packet": 144800000, + "q65": 144116000 + }, + "tags": ["hamradio"] + }, + { + "name": "70cm", + "lower_bound": 430000000, + "upper_bound": 440000000, + "frequencies": { + "pocsag": 439987500, + "q65": 432065000 + }, + "tags": ["hamradio"] + }, + { + "name": "23cm", + "lower_bound": 1240000000, + "upper_bound": 1300000000, + "frequencies": { + "q65": 1296065000 + }, + "tags": ["hamradio"] + }, + { + "name": "13cm", + "lower_bound": 2320000000, + "upper_bound": 2450000000, + "frequencies": { + "q65": [2301065000, 2304065000, 2320065000] + }, + "tags": ["hamradio"] + }, + { + "name": "9cm", + "lower_bound": 3400000000, + "upper_bound": 3475000000, + "frequencies": { + "q65": 3400065000 + }, + "tags": ["hamradio"] + }, + { + "name": "6cm", + "lower_bound": 5650000000, + "upper_bound": 5850000000, + "frequencies": { + "q65": 5760200000 + }, + "tags": ["hamradio"] + }, + { + "name": "3cm", + "lower_bound": 10000000000, + "upper_bound": 10500000000, + "frequencies": { + "q65": 10368200000 + }, + "tags": ["hamradio"] + }, + { + "name": "120m Broadcast", + "lower_bound": 2300000, + "upper_bound": 2495000, + "tags": ["broadcast"] + }, + { + "name": "90m Broadcast", + "lower_bound": 3200000, + "upper_bound": 3400000, + "tags": ["broadcast"] + }, + { + "name": "75m Broadcast", + "lower_bound": 3900000, + "upper_bound": 4000000, + "tags": ["broadcast"] + }, + { + "name": "60m Broadcast", + "lower_bound": 4750000, + "upper_bound": 4995000, + "tags": ["broadcast"] + }, + { + "name": "49m Broadcast", + "lower_bound": 5900000, + "upper_bound": 6200000, + "tags": ["broadcast"] + }, + { + "name": "41m Broadcast", + "lower_bound": 7200000, + "upper_bound": 7450000, + "tags": ["broadcast"] + }, + { + "name": "31m Broadcast", + "lower_bound": 9400000, + "upper_bound": 9900000, + "tags": ["broadcast"] + }, + { + "name": "25m Broadcast", + "lower_bound": 11600000, + "upper_bound": 12100000, + "tags": ["broadcast"] + }, + { + "name": "22m Broadcast", + "lower_bound": 13570000, + "upper_bound": 13870000, + "tags": ["broadcast"] + }, + { + "name": "19m Broadcast", + "lower_bound": 15100000, + "upper_bound": 15830000, + "tags": ["broadcast"] + }, + { + "name": "16m Broadcast", + "lower_bound": 17480000, + "upper_bound": 17900000, + "tags": ["broadcast"] + }, + { + "name": "15m Broadcast", + "lower_bound": 18900000, + "upper_bound": 19020000, + "tags": ["broadcast"] + }, + { + "name": "13m Broadcast", + "lower_bound": 21450000, + "upper_bound": 21850000, + "tags": ["broadcast"] + }, + { + "name": "11m Broadcast", + "lower_bound": 25670000, + "upper_bound": 26100000, + "tags": ["broadcast"] + }, + { + "name": "FM Broadcast", + "lower_bound": 87500000, + "upper_bound": 108000000, + "tags": ["broadcast"] + }, + { + "name": "11m CB", + "lower_bound": 26965000, + "upper_bound": 27405000, + "frequencies": { + "js8": 27245000 + }, + "tags": ["public"] + }, + { + "name": "PMR446", + "lower_bound": 446000000, + "upper_bound": 446200000, + "tags": ["public"] + } +] \ No newline at end of file diff --git a/openwebrx/config_webrx.py b/openwebrx/config_webrx.py new file mode 100644 index 0000000..f06f50e --- /dev/null +++ b/openwebrx/config_webrx.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- + +""" +config_webrx: configuration options for OpenWebRX + + This file is part of OpenWebRX, + an open-source SDR receiver software with a web UI. + Copyright (c) 2013-2015 by Andras Retzler + Copyright (c) 2019-2021 by Jakob Ketterl + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + In addition, as a special exception, the copyright holders + state that config_rtl.py and config_webrx.py are not part of the + Corresponding Source defined in GNU AGPL version 3 section 1. + + (It means that you do not have to redistribute config_rtl.py and + config_webrx.py if you make any changes to these two configuration files, + and use them for running your web service with OpenWebRX.) +""" + +""" +DEPRECATION notice + +As of OpenWebRX 0.21, the configuration system has been completely overhauled. +The configuration of OpenWebRX should now be done in the new web-based +configuration interface exclusively. + +Existing configurations can still be used, but their values will be migrated +to the new storage infrastructure as soon as the web configuration is used to +edit them. + +The new configuration storage is not intended to be edited manually. +""" + +# configuration version. please only modify if you're able to perform the associated migration steps. +version = 7 + +# NOTE: you can find additional information about configuring OpenWebRX in the Wiki: +# https://github.com/jketterl/openwebrx/wiki/Configuration-guide + +# ==== Server settings ==== +#max_clients = 20 + +# ==== Web GUI configuration ==== +#receiver_name = "[Callsign]" +#receiver_location = "Budapest, Hungary" +#receiver_asl = 200 +#receiver_admin = "example@example.com" +#receiver_gps = {"lat": 47.000000, "lon": 19.000000} +#photo_title = "Panorama of Budapest from Schönherz Zoltán Dormitory" +# photo_desc allows you to put pretty much any HTML you like into the receiver description. +# The lines below should give you some examples of what's possible. +#photo_desc = """ +#You can add your own background photo and receiver information.
+#Receiver is operated by: Receiver Operator
+#Device: Receiver Device
+#Antenna: Receiver Antenna
+#Website: http://localhost +#""" + +# ==== Public receiver listings ==== +# You can publish your receiver on online receiver directories, like https://www.receiverbook.de +# You will receive a receiver key from the directory that will authenticate you as the operator of this receiver. +# Please note that you not share your receiver keys publicly since anyone that obtains your receiver key can take over +# your public listing. +# Your receiver keys should be placed into this array: +#receiver_keys = [] +# If you list your receiver on multiple sites, you can place all your keys into the array above, or you can append +# keys to the arraylike this: +# receiver_keys += ["my-receiver-key"] + +# If you're not sure, simply copy & paste the code you received from your listing site below this line: + +# ==== DSP/RX settings ==== +#fft_fps = 9 +#fft_size = 4096 # Should be power of 2 +#fft_voverlap_factor = ( +# 0.3 # If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram. +#) + +#audio_compression = "adpcm" # valid values: "adpcm", "none" +#fft_compression = "adpcm" # valid values: "adpcm", "none" + +# Tau setting for WFM (broadcast FM) deemphasis\ +# Quote from wikipedia https://en.wikipedia.org/wiki/FM_broadcasting#Pre-emphasis_and_de-emphasis +# "In most of the world a 50 µs time constant is used. In the Americas and South Korea, 75 µs is used" +# Enable one of the following lines, depending on your location: +# wfm_deemphasis_tau = 75e-6 # for US and South Korea +#wfm_deemphasis_tau = 50e-6 # for the rest of the world + +#digimodes_fft_size = 2048 + +# enables lookup of DMR ids using the radioid api +#digital_voice_dmr_id_lookup = True + +""" +Note: if you experience audio underruns while CPU usage is 100%, you can: +- decrease `samp_rate`, +- set `fft_voverlap_factor` to 0, +- decrease `fft_fps` and `fft_size`, +- limit the number of users by decreasing `max_clients`. +""" + +# ==== I/Q sources ==== +# (Uncomment the appropriate by removing # characters at the beginning of the corresponding lines.) + +############################################################################### +# Is my SDR hardware supported? # +# Check here: https://github.com/jketterl/openwebrx/wiki/Supported-Hardware # +############################################################################### + +# Currently supported types of sdr receivers: +# "rtl_sdr", "rtl_sdr_soapy", "sdrplay", "hackrf", "airspy", "airspyhf", "fifi_sdr", +# "perseussdr", "lime_sdr", "pluto_sdr", "soapy_remote", "hpsdr", "uhd", +# "radioberry", "fcdpp", "rtl_tcp", "sddc", "runds" + +# For more details on specific types, please checkout the wiki: +# https://github.com/jketterl/openwebrx/wiki/Supported-Hardware#sdr-devices + +#sdrs = { +# "rtlsdr": { +# "name": "RTL-SDR USB Stick", +# "type": "rtl_sdr", +# "ppm": 0, +# # you can change this if you use an upconverter. formula is: +# # center_freq + lfo_offset = actual frequency on the sdr +# # "lfo_offset": 0, +# "profiles": { +# "70cm": { +# "name": "70cm Relais", +# "center_freq": 438800000, +# "rf_gain": 29, +# "samp_rate": 2400000, +# "start_freq": 439275000, +# "start_mod": "nfm", +# }, +# "2m": { +# "name": "2m komplett", +# "center_freq": 145000000, +# "rf_gain": 29, +# "samp_rate": 2048000, +# "start_freq": 145725000, +# "start_mod": "nfm", +# }, +# }, +# }, +# "airspy": { +# "name": "Airspy HF+", +# "type": "airspyhf", +# "ppm": 0, +# "rf_gain": "auto", +# "profiles": { +# "20m": { +# "name": "20m", +# "center_freq": 14150000, +# "samp_rate": 384000, +# "start_freq": 14070000, +# "start_mod": "usb", +# }, +# "30m": { +# "name": "30m", +# "center_freq": 10125000, +# "samp_rate": 192000, +# "start_freq": 10142000, +# "start_mod": "usb", +# }, +# "40m": { +# "name": "40m", +# "center_freq": 7100000, +# "samp_rate": 256000, +# "start_freq": 7070000, +# "start_mod": "lsb", +# }, +# "80m": { +# "name": "80m", +# "center_freq": 3650000, +# "samp_rate": 384000, +# "start_freq": 3570000, +# "start_mod": "lsb", +# }, +# "49m": { +# "name": "49m Broadcast", +# "center_freq": 6050000, +# "samp_rate": 384000, +# "start_freq": 6070000, +# "start_mod": "am", +# }, +# }, +# }, +# "sdrplay": { +# "name": "SDRPlay RSP2", +# "type": "sdrplay", +# "ppm": 0, +# "antenna": "Antenna A", +# "profiles": { +# "20m": { +# "name": "20m", +# "center_freq": 14150000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 14070000, +# "start_mod": "usb", +# }, +# "30m": { +# "name": "30m", +# "center_freq": 10125000, +# "rf_gain": 0, +# "samp_rate": 250000, +# "start_freq": 10142000, +# "start_mod": "usb", +# }, +# "40m": { +# "name": "40m", +# "center_freq": 7100000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 7070000, +# "start_mod": "lsb", +# }, +# "80m": { +# "name": "80m", +# "center_freq": 3650000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 3570000, +# "start_mod": "lsb", +# }, +# "49m": { +# "name": "49m Broadcast", +# "center_freq": 6000000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 6070000, +# "start_mod": "am", +# }, +# }, +# }, +#} + +# ==== Color themes ==== + +### google turbo colormap (see: https://ai.googleblog.com/2019/08/turbo-improved-rainbow-colormap-for.html) +#waterfall_scheme = "GoogleTurboWaterfall" + +### original theme by teejez: +#waterfall_scheme = "TeejeezWaterfall" + +### old theme by HA7ILM: +#waterfall_scheme = "Ha7ilmWaterfall" +##For the old colors, you might also want to set [fft_voverlap_factor] to 0. + +### custom waterfall schemes can be configured like this: +#waterfall_scheme = "CustomWaterfall" +#waterfall_colors = [0x0000FF, 0x00FF00, 0xFF0000] + +### Waterfall calibration +#waterfall_levels = {"min": -88, "max": -20} # in dB + +#waterfall_auto_levels = {"min": 3, "max": 10} +#waterfall_auto_min_range = 50 + +# Note: When the auto waterfall level button is clicked, the following happens: +# [waterfall_levels.min] = [current_min_power_level] - [waterfall_auto_levels["min"]] +# [waterfall_levels.max] = [current_max_power_level] + [waterfall_auto_levels["max"]] +# +# ___|__________________________________|____________________________________|__________________________________|___> signal power +# \_waterfall_auto_levels["min"]_/ |__ current_min_power_level | \_waterfall_auto_levels["max"]_/ +# current_max_power_level __| + +# This setting allows you to modify the precision of the frequency displays in OpenWebRX. +# Set this to exponent of 10 to select the most precise digit in Hz you'd like to see +# examples: +# a value of 2 selects 10^2 = 100Hz tuning precision (default): +#tuning_precision = 2 +# a value of 1 selects 10^1 = 10Hz tuning precision: +#tuning_precision = 1 + +# This setting tells the auto-squelch the offset to add to the current signal level to use as the new squelch level. +# Lowering this setting will give you a more sensitive squelch, but it may also cause unwanted squelch openings when +# using the auto squelch. +#squelch_auto_margin = 10 # in dB + +#google_maps_api_key = "" + +# how long should positions be visible on the map? +# they will start fading out after half of that +# in seconds; default: 2 hours +#map_position_retention_time = 2 * 60 * 60 + +# decoder queue configuration +# due to the nature of some operating modes (ft8, ft8, jt9, jt65, wspr and js8), the data is recorded for a given amount +# of time (6 seconds up to 2 minutes) and decoded at the end. this can lead to very high peak loads. +# to mitigate this, the recordings will be queued and processed in sequence. +# the number of workers will limit the total amount of work (one worker will losely occupy one cpu / thread) +#decoding_queue_workers = 2 +# the maximum queue length will cause decodes to be dumped if the workers cannot keep up +# if you are running background services, make sure this number is high enough to accept the task influx during peaks +# i.e. this should be higher than the number of decoding services running at the same time +#decoding_queue_length = 10 + +# wsjt decoding depth will allow more results, but will also consume more cpu +#wsjt_decoding_depth = 3 +# can also be set for each mode separately +# jt65 seems to be somewhat prone to erroneous decodes, this setting handles that to some extent +#wsjt_decoding_depths = {"jt65": 1} + +# FST4 can be transmitted in different intervals. This setting determines which intervals will be decoded. +# available values (in seconds): 15, 30, 60, 120, 300, 900, 1800 +#fst4_enabled_intervals = [15, 30] + +# FST4W can be transmitted in different intervals. This setting determines which intervals will be decoded. +# available values (in seconds): 120, 300, 900, 1800 +#fst4w_enabled_intervals = [120, 300] + +# Q65 allows many combinations of intervals and submodes. This setting determines which combinations will be decoded. +# Please use the mode letter followed by the decode interval in seconds to specify the combinations. For example: +#q65_enabled_combinations = ["A30", "E120", "C60"] + +# JS8 comes in different speeds: normal, slow, fast, turbo. This setting controls which ones are enabled. +#js8_enabled_profiles = ["normal", "slow"] +# JS8 decoding depth; higher value will get more results, but will also consume more cpu +#js8_decoding_depth = 3 + +# Enable background service for decoding digital data. You can find more information at: +# https://github.com/jketterl/openwebrx/wiki/Background-decoding +#services_enabled = False +#services_decoders = ["ft8", "ft4", "wspr", "packet"] + +# === aprs igate settings === +# If you want to share your APRS decodes with the aprs network, configure these settings accordingly. +# Make sure that you have set services_enabled to true and customize services_decoders to your needs. +#aprs_callsign = "N0CALL" +#aprs_igate_enabled = False +#aprs_igate_server = "euro.aprs2.net" +#aprs_igate_password = "" +# beacon uses the receiver_gps setting, so if you enable this, make sure the location is correct there +#aprs_igate_beacon = False + +# Uncomment the following to customize gateway beacon details reported to the aprs network +# Plese see Dire Wolf's documentation on PBEACON configuration for complete details: +# https://github.com/wb2osz/direwolf/raw/master/doc/User-Guide.pdf + +# Symbol in its two-character form as specified by the APRS spec at http://www.aprs.org/symbols/symbols-new.txt +# Default: Receive only IGate (do not send msgs back to RF) +# aprs_igate_symbol = "R&" + +# Custom comment about igate +# Default: OpenWebRX APRS gateway +# aprs_igate_comment = "OpenWebRX APRS gateway" + +# Antenna Height and Gain details +# Unspecified by default +# Antenna height above average terrain (HAAT) in meters +# aprs_igate_height = "5" +# Antenna gain in dBi +# aprs_igate_gain = "0" +# Antenna direction (N, NE, E, SE, S, SW, W, NW). Omnidirectional by default +# aprs_igate_dir = "NE" + +# === PSK Reporter settings === +# enable this if you want to upload all ft8, ft4 etc spots to pskreporter.info +# this also uses the receiver_gps setting from above, so make sure it contains a correct locator +#pskreporter_enabled = False +#pskreporter_callsign = "N0CALL" +# optional antenna information, uncomment to enable +#pskreporter_antenna_information = "Dipole" + +# === WSPRNet reporting settings +# enable this if you want to upload WSPR spots to wsprnet.ort +# in addition to these settings also make sure that receiver_gps contains your correct location +#wsprnet_enabled = False +#wsprnet_callsign = "N0CALL" diff --git a/openwebrx/csdr/__init__.py b/openwebrx/csdr/__init__.py new file mode 100644 index 0000000..32e2b09 --- /dev/null +++ b/openwebrx/csdr/__init__.py @@ -0,0 +1,837 @@ +""" +OpenWebRX csdr plugin: do the signal processing with csdr + + This file is part of OpenWebRX, + an open-source SDR receiver software with a web UI. + Copyright (c) 2013-2015 by Andras Retzler + Copyright (c) 2019-2021 by Jakob Ketterl + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +""" + +import subprocess +import os +import signal +import threading +import math +from functools import partial + +from csdr.output import Output + +from owrx.kiss import KissClient, DirewolfConfig, DirewolfConfigSubscriber +from owrx.audio.chopper import AudioChopper + +from csdr.pipe import Pipe + +import logging + +logger = logging.getLogger(__name__) + + +class Dsp(DirewolfConfigSubscriber): + def __init__(self, output: Output): + self.samp_rate = 250000 + self.output_rate = 11025 + self.hd_output_rate = 44100 + self.fft_size = 1024 + self.fft_fps = 5 + self.center_freq = 0 + self.offset_freq = 0 + self.low_cut = -4000 + self.high_cut = 4000 + self.bpf_transition_bw = 320 # Hz, and this is a constant + self.ddc_transition_bw_rate = 0.15 # of the IF sample rate + self.running = False + self.secondary_processes_running = False + self.audio_compression = "none" + self.fft_compression = "none" + self.demodulator = "nfm" + self.name = "csdr" + self.base_bufsize = 512 + self.decimation = None + self.last_decimation = None + self.nc_port = None + self.squelch_level = -150 + self.fft_averages = 50 + self.wfm_deemphasis_tau = 50e-6 + self.iqtee = False + self.iqtee2 = False + self.secondary_demodulator = None + self.secondary_fft_size = 1024 + self.secondary_process_fft = None + self.secondary_process_demod = None + self.pipe_names = { + "bpf_pipe": Pipe.WRITE, + "shift_pipe": Pipe.WRITE, + "squelch_pipe": Pipe.WRITE, + "smeter_pipe": Pipe.READ, + "meta_pipe": Pipe.READ, + "iqtee_pipe": Pipe.NONE, + "iqtee2_pipe": Pipe.NONE, + "dmr_control_pipe": Pipe.WRITE, + } + self.pipes = {} + self.secondary_pipe_names = {"secondary_shift_pipe": Pipe.WRITE} + self.secondary_offset_freq = 1000 + self.codecserver = None + self.modification_lock = threading.Lock() + self.output = output + + self.temporary_directory = None + self.pipe_base_path = None + self.set_temporary_directory("/tmp") + + self.is_service = False + self.direwolf_config = None + self.direwolf_config_path = None + self.process = None + + def set_service(self, flag=True): + self.is_service = flag + + def set_temporary_directory(self, what): + self.temporary_directory = what + self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_".format(tmp_dir=self.temporary_directory) + + def chain(self, which): + chain = ["nc -v 127.0.0.1 {nc_port}"] + if which == "fft": + chain += [ + "csdr fft_cc {fft_size} {fft_block_size}", + "csdr logpower_cf -70" + if self.fft_averages == 0 + else "csdr logaveragepower_cf -70 {fft_size} {fft_averages}", + "csdr fft_exchange_sides_ff {fft_size}", + ] + if self.fft_compression == "adpcm": + chain += ["csdr compress_fft_adpcm_f_u8 {fft_size}"] + return chain + chain += ["csdr shift_addfast_cc --fifo {shift_pipe}"] + if self.decimation > 1: + chain += ["csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING"] + chain += ["csdr bandpass_fir_fft_cc --fifo {bpf_pipe} {bpf_transition_bw} HAMMING"] + if self.output.supports_type("smeter"): + chain += [ + "csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}" + ] + if self.secondary_demodulator: + if self.output.supports_type("secondary_fft"): + chain += ["csdr tee {iqtee_pipe}"] + chain += ["csdr tee {iqtee2_pipe}"] + # early exit if we don't want audio + if not self.output.supports_type("audio"): + return chain + # safe some cpu cycles... no need to decimate if decimation factor is 1 + last_decimation_block = [] + if self.last_decimation >= 2.0: + # activate prefilter if signal has been oversampled, e.g. WFM + last_decimation_block = ["csdr fractional_decimator_ff {last_decimation} 12 --prefilter"] + elif self.last_decimation != 1.0: + last_decimation_block = ["csdr fractional_decimator_ff {last_decimation}"] + if which == "nfm": + chain += ["csdr fmdemod_quadri_cf", "csdr limit_ff"] + chain += last_decimation_block + chain += [ + "csdr deemphasis_nfm_ff {audio_rate}", + "csdr agc_ff --profile slow --max 3", + ] + if self.get_audio_rate() != self.get_output_rate(): + chain += [ + "sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - " + ] + else: + chain += ["csdr convert_f_s16"] + elif which == "wfm": + chain += [ + "csdr fmdemod_quadri_cf", + "csdr limit_ff", + ] + chain += last_decimation_block + chain += ["csdr deemphasis_wfm_ff {audio_rate} {wfm_deemphasis_tau}", "csdr convert_f_s16"] + elif self.isDigitalVoice(which): + chain += ["csdr fmdemod_quadri_cf"] + chain += last_decimation_block + chain += ["dc_block"] + # m17 + if which == "m17": + chain += [ + "csdr limit_ff", + "csdr convert_f_s16", + "m17-demod", + ] + else: + # digiham modes + if which == "dstar": + chain += [ + "fsk_demodulator -s 10", + "dstar_decoder --fifo {meta_pipe}", + "mbe_synthesizer -d {codecserver_arg}", + ] + elif which == "nxdn": + chain += [ + "rrc_filter --narrow", + "gfsk_demodulator --samples 20", + "nxdn_decoder --fifo {meta_pipe}", + "mbe_synthesizer {codecserver_arg}", + ] + else: + chain += ["rrc_filter", "gfsk_demodulator"] + if which == "dmr": + chain += [ + "dmr_decoder --fifo {meta_pipe} --control-fifo {dmr_control_pipe}", + "mbe_synthesizer {codecserver_arg}", + ] + elif which == "ysf": + chain += ["ysf_decoder --fifo {meta_pipe}", "mbe_synthesizer -y {codecserver_arg}"] + chain += ["digitalvoice_filter"] + chain += [ + "CSDR_FIXED_BUFSIZE=32 csdr agc_s16 --max 30 --initial 3", + "sox -t raw -r 8000 -e signed-integer -b 16 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ", + ] + elif which == "am": + chain += ["csdr amdemod_cf", "csdr fastdcblock_ff"] + chain += last_decimation_block + chain += [ + "csdr agc_ff --profile slow --initial 200", + "csdr convert_f_s16", + ] + elif self.isFreeDV(which): + chain += ["csdr realpart_cf"] + chain += last_decimation_block + chain += [ + "csdr agc_ff", + "csdr convert_f_s16", + "freedv_rx 1600 - -", + "csdr agc_s16 --max 30 --initial 3", + "sox -t raw -r 8000 -e signed-integer -b 16 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ", + ] + elif self.isDrm(which): + if self.last_decimation != 1.0: + # we are still dealing with complex samples here, so the regular last_decimation_block doesn't fit + chain += ["csdr fractional_decimator_cc {last_decimation}"] + chain += [ + "csdr convert_f_s16", + "dream -c 6 --sigsrate 48000 --audsrate 48000 -I - -O -", + "sox -t raw -r 48000 -e signed-integer -b 16 -c 2 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ", + ] + elif which == "ssb": + chain += ["csdr realpart_cf"] + chain += last_decimation_block + chain += ["csdr agc_ff"] + # fixed sample rate necessary for the wsjt-x tools. fix with sox... + if self.get_audio_rate() != self.get_output_rate(): + chain += [ + "sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - " + ] + else: + chain += ["csdr convert_f_s16"] + + if self.audio_compression == "adpcm": + chain += ["csdr encode_ima_adpcm_i16_u8"] + return chain + + def secondary_chain(self, which): + chain = ["cat {input_pipe}"] + if which == "fft": + chain += [ + "csdr fft_cc {secondary_fft_input_size} {secondary_fft_block_size}", + "csdr logpower_cf -70" + if self.fft_averages == 0 + else "csdr logaveragepower_cf -70 {secondary_fft_size} {fft_averages}", + "csdr fft_exchange_sides_ff {secondary_fft_input_size}", + ] + if self.fft_compression == "adpcm": + chain += ["csdr compress_fft_adpcm_f_u8 {secondary_fft_size}"] + return chain + elif which == "bpsk31" or which == "bpsk63": + return chain + [ + "csdr shift_addfast_cc --fifo {secondary_shift_pipe}", + "csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff}", + "csdr simple_agc_cc 0.001 0.5", + "csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q", + "CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8", + "CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8", + ] + elif self.isWsjtMode(which) or self.isJs8(which): + chain += ["csdr realpart_cf"] + if self.last_decimation != 1.0: + chain += ["csdr fractional_decimator_ff {last_decimation}"] + return chain + ["csdr agc_ff", "csdr convert_f_s16"] + elif which == "packet": + chain += ["csdr fmdemod_quadri_cf"] + if self.last_decimation != 1.0: + chain += ["csdr fractional_decimator_ff {last_decimation}"] + return chain + ["csdr convert_f_s16", "direwolf -c {direwolf_config} -r {audio_rate} -t 0 -q d -q h 1>&2"] + elif which == "pocsag": + chain += ["csdr fmdemod_quadri_cf"] + if self.last_decimation != 1.0: + chain += ["csdr fractional_decimator_ff {last_decimation}"] + return chain + ["fsk_demodulator -i", "pocsag_decoder"] + + def set_secondary_demodulator(self, what): + if self.get_secondary_demodulator() == what: + return + self.secondary_demodulator = what + self.calculate_decimation() + self.restart() + + def secondary_fft_block_size(self): + base = (self.samp_rate / self.decimation) / (self.fft_fps * 2) + if self.fft_averages == 0: + return base + return base / self.fft_averages + + def secondary_decimation(self): + return 1 # currently unused + + def secondary_bpf_cutoff(self): + if self.secondary_demodulator == "bpsk31": + return 31.25 / self.if_samp_rate() + elif self.secondary_demodulator == "bpsk63": + return 62.5 / self.if_samp_rate() + return 0 + + def secondary_bpf_transition_bw(self): + if self.secondary_demodulator == "bpsk31": + return 31.25 / self.if_samp_rate() + elif self.secondary_demodulator == "bpsk63": + return 62.5 / self.if_samp_rate() + return 0 + + def secondary_samples_per_bits(self): + if self.secondary_demodulator == "bpsk31": + return int(round(self.if_samp_rate() / 31.25)) & ~3 + elif self.secondary_demodulator == "bpsk63": + return int(round(self.if_samp_rate() / 62.5)) & ~3 + return 0 + + def secondary_bw(self): + if self.secondary_demodulator == "bpsk31": + return 31.25 + elif self.secondary_demodulator == "bpsk63": + return 62.5 + + def start_secondary_demodulator(self): + if not self.secondary_demodulator: + return + logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate()) + secondary_command_demod = " | ".join(self.secondary_chain(self.secondary_demodulator)) + self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod) + self.try_create_configs(secondary_command_demod) + + secondary_command_demod = secondary_command_demod.format( + input_pipe=self.pipes["iqtee2_pipe"], + secondary_shift_pipe=self.pipes["secondary_shift_pipe"], + secondary_decimation=self.secondary_decimation(), + secondary_samples_per_bits=self.secondary_samples_per_bits(), + secondary_bpf_cutoff=self.secondary_bpf_cutoff(), + secondary_bpf_transition_bw=self.secondary_bpf_transition_bw(), + if_samp_rate=self.if_samp_rate(), + last_decimation=self.last_decimation, + audio_rate=self.get_audio_rate(), + direwolf_config=self.direwolf_config_path, + ) + + logger.debug("secondary command (demod) = %s", secondary_command_demod) + if self.output.supports_type("secondary_fft"): + secondary_command_fft = " | ".join(self.secondary_chain("fft")) + secondary_command_fft = secondary_command_fft.format( + input_pipe=self.pipes["iqtee_pipe"], + secondary_fft_input_size=self.secondary_fft_size, + secondary_fft_size=self.secondary_fft_size, + secondary_fft_block_size=self.secondary_fft_block_size(), + fft_averages=self.fft_averages, + ) + logger.debug("secondary command (fft) = %s", secondary_command_fft) + + self.secondary_process_fft = subprocess.Popen( + secondary_command_fft, stdout=subprocess.PIPE, shell=True, start_new_session=True + ) + self.output.send_output( + "secondary_fft", + partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())), + ) + + # direwolf does not provide any meaningful data on stdout + # more specifically, it doesn't provide any data. if however, for any strange reason, it would start to do so, + # it would block if not read. by piping it to devnull, we avoid a potential pitfall here. + secondary_output = subprocess.DEVNULL if self.isPacket() else subprocess.PIPE + self.secondary_process_demod = subprocess.Popen( + secondary_command_demod, stdout=secondary_output, shell=True, start_new_session=True + ) + self.secondary_processes_running = True + + if self.isWsjtMode() or self.isJs8(): + chopper = AudioChopper(self, self.get_secondary_demodulator()) + chopper.send_output("audio", self.secondary_process_demod.stdout.read) + output_type = "js8_demod" if self.isJs8() else "wsjt_demod" + self.output.send_output(output_type, chopper.read) + elif self.isPacket(): + # we best get the ax25 packets from the kiss socket + kiss = KissClient(self.direwolf_config.getPort()) + self.output.send_output("packet_demod", kiss.read) + elif self.isPocsag(): + self.output.send_output("pocsag_demod", self.secondary_process_demod.stdout.readline) + else: + self.output.send_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1)) + + # open control pipes for csdr and send initialization data + if self.has_pipe("secondary_shift_pipe"): # TODO digimodes + self.set_secondary_offset_freq(self.secondary_offset_freq) # TODO digimodes + + def set_secondary_offset_freq(self, value): + self.secondary_offset_freq = value + if self.secondary_processes_running and self.has_pipe("secondary_shift_pipe"): + self.pipes["secondary_shift_pipe"].write( + "%g\n" % (-float(self.secondary_offset_freq) / self.if_samp_rate()) + ) + + def stop_secondary_demodulator(self): + if not self.secondary_processes_running: + return + self.try_delete_pipes(self.secondary_pipe_names) + self.try_delete_configs() + if self.secondary_process_fft: + try: + os.killpg(os.getpgid(self.secondary_process_fft.pid), signal.SIGTERM) + # drain any leftover data to free file descriptors + self.secondary_process_fft.communicate() + self.secondary_process_fft = None + except ProcessLookupError: + # been killed by something else, ignore + pass + if self.secondary_process_demod: + try: + os.killpg(os.getpgid(self.secondary_process_demod.pid), signal.SIGTERM) + # drain any leftover data to free file descriptors + self.secondary_process_demod.communicate() + self.secondary_process_demod = None + except ProcessLookupError: + # been killed by something else, ignore + pass + self.secondary_processes_running = False + + def get_secondary_demodulator(self): + return self.secondary_demodulator + + def set_secondary_fft_size(self, secondary_fft_size): + if self.secondary_fft_size == secondary_fft_size: + return + self.secondary_fft_size = secondary_fft_size + self.restart() + + def set_audio_compression(self, what): + if self.audio_compression == what: + return + self.audio_compression = what + self.restart() + + def get_audio_bytes_to_read(self): + # desired latency: 5ms + # uncompressed audio has 16 bits = 2 bytes per sample + base = self.output_rate * 0.005 * 2 + # adpcm compresses the bitstream by 4 + if self.audio_compression == "adpcm": + base = base / 4 + return int(base) + + def set_fft_compression(self, what): + if self.fft_compression == what: + return + self.fft_compression = what + self.restart() + + def get_fft_bytes_to_read(self): + if self.fft_compression == "none": + return self.fft_size * 4 + if self.fft_compression == "adpcm": + return int((self.fft_size / 2) + (10 / 2)) + + def get_secondary_fft_bytes_to_read(self): + if self.fft_compression == "none": + return self.secondary_fft_size * 4 + if self.fft_compression == "adpcm": + return (self.secondary_fft_size / 2) + (10 / 2) + + def set_samp_rate(self, samp_rate): + self.samp_rate = samp_rate + self.calculate_decimation() + if self.running: + self.restart() + + def calculate_decimation(self): + (self.decimation, self.last_decimation) = self.get_decimation(self.samp_rate, self.get_audio_rate()) + + def get_decimation(self, input_rate, output_rate): + if output_rate <= 0: + raise ValueError("invalid output rate: {rate}".format(rate=output_rate)) + decimation = 1 + target_rate = output_rate + # wideband fm has a much higher frequency deviation (75kHz). + # we cannot cover this if we immediately decimate to the sample rate the audio will have later on, so we need + # to compensate here. + if self.get_demodulator() == "wfm" and output_rate < 200000: + target_rate = 200000 + while input_rate / (decimation + 1) >= target_rate: + decimation += 1 + fraction = float(input_rate / decimation) / output_rate + return decimation, fraction + + def if_samp_rate(self): + return self.samp_rate / self.decimation + + def get_name(self): + return self.name + + def get_output_rate(self): + return self.output_rate + + def get_hd_output_rate(self): + return self.hd_output_rate + + def get_audio_rate(self): + if self.isDigitalVoice() or self.isPacket() or self.isPocsag() or self.isDrm(): + return 48000 + elif self.isWsjtMode() or self.isJs8(): + return 12000 + elif self.isFreeDV(): + return 8000 + elif self.isHdAudio(): + return self.get_hd_output_rate() + return self.get_output_rate() + + def isDigitalVoice(self, demodulator=None): + if demodulator is None: + demodulator = self.get_demodulator() + return demodulator in ["dmr", "dstar", "nxdn", "ysf", "m17"] + + def isWsjtMode(self, demodulator=None): + if demodulator is None: + demodulator = self.get_secondary_demodulator() + return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4", "fst4", "fst4w", "q65"] + + def isJs8(self, demodulator=None): + if demodulator is None: + demodulator = self.get_secondary_demodulator() + return demodulator == "js8" + + def isPacket(self, demodulator=None): + if demodulator is None: + demodulator = self.get_secondary_demodulator() + return demodulator == "packet" + + def isPocsag(self, demodulator=None): + if demodulator is None: + demodulator = self.get_secondary_demodulator() + return demodulator == "pocsag" + + def isFreeDV(self, demodulator=None): + if demodulator is None: + demodulator = self.get_demodulator() + return demodulator == "freedv" + + def isHdAudio(self, demodulator=None): + if demodulator is None: + demodulator = self.get_demodulator() + return demodulator == "wfm" + + def isDrm(self, demodulator=None): + if demodulator is None: + demodulator = self.get_demodulator() + return demodulator == "drm" + + def set_output_rate(self, output_rate): + if self.output_rate == output_rate: + return + self.output_rate = output_rate + self.calculate_decimation() + self.restart() + + def set_hd_output_rate(self, hd_output_rate): + if self.hd_output_rate == hd_output_rate: + return + self.hd_output_rate = hd_output_rate + self.calculate_decimation() + self.restart() + + def set_demodulator(self, demodulator): + if demodulator in ["usb", "lsb", "cw"]: + demodulator = "ssb" + if self.demodulator == demodulator: + return + self.demodulator = demodulator + self.calculate_decimation() + self.restart() + + def get_demodulator(self): + return self.demodulator + + def set_fft_size(self, fft_size): + if self.fft_size == fft_size: + return + self.fft_size = fft_size + self.restart() + + def set_fft_fps(self, fft_fps): + self.fft_fps = fft_fps + self.restart() + + def set_fft_averages(self, fft_averages): + self.fft_averages = fft_averages + self.restart() + + def fft_block_size(self): + if self.fft_averages == 0: + return self.samp_rate / self.fft_fps + else: + return self.samp_rate / self.fft_fps / self.fft_averages + + def set_offset_freq(self, offset_freq): + if offset_freq is None: + return + self.offset_freq = offset_freq + if self.running: + self.pipes["shift_pipe"].write("%g\n" % (-float(self.offset_freq) / self.samp_rate)) + + def set_center_freq(self, center_freq): + # dsp only needs to know this to be able to pass it to decoders in the form of get_operating_freq() + self.center_freq = center_freq + + def get_operating_freq(self): + return self.center_freq + self.offset_freq + + def set_bandpass(self, bandpass): + self.set_bpf(bandpass.low_cut, bandpass.high_cut) + + def set_bpf(self, low_cut, high_cut): + self.low_cut = low_cut + self.high_cut = high_cut + if self.running: + self.pipes["bpf_pipe"].write( + "%g %g\n" % (float(self.low_cut) / self.if_samp_rate(), float(self.high_cut) / self.if_samp_rate()) + ) + + def get_bpf(self): + return [self.low_cut, self.high_cut] + + def convertToLinear(self, db): + return float(math.pow(10, db / 10)) + + def set_squelch_level(self, squelch_level): + self.squelch_level = squelch_level + # no squelch required on digital voice modes + actual_squelch = ( + -150 + if self.isDigitalVoice() or self.isPacket() or self.isPocsag() or self.isFreeDV() or self.isDrm() + else self.squelch_level + ) + if self.running: + self.pipes["squelch_pipe"].write("%g\n" % (self.convertToLinear(actual_squelch))) + + def set_codecserver(self, s): + if self.codecserver == s: + return + self.codecserver = s + self.restart() + + def get_codecserver_arg(self): + return "-s {}".format(self.codecserver) if self.codecserver else "" + + def set_dmr_filter(self, filter): + if self.has_pipe("dmr_control_pipe"): + self.pipes["dmr_control_pipe"].write("{0}\n".format(filter)) + + def set_wfm_deemphasis_tau(self, tau): + if self.wfm_deemphasis_tau == tau: + return + self.wfm_deemphasis_tau = tau + self.restart() + + def ddc_transition_bw(self): + return self.ddc_transition_bw_rate * (self.if_samp_rate() / float(self.samp_rate)) + + def try_create_pipes(self, pipe_names, command_base): + for pipe_name, pipe_type in pipe_names.items(): + if self.has_pipe(pipe_name): + logger.warning("{pipe_name} is still in use", pipe_name=pipe_name) + self.pipes[pipe_name].close() + if "{" + pipe_name + "}" in command_base: + p = self.pipe_base_path + pipe_name + encoding = None + # TODO make digiham output unicode and then change this here + # the whole pipe enoding feature onlye exists because of this + if pipe_name == "meta_pipe": + encoding = "cp437" + self.pipes[pipe_name] = Pipe.create(p, pipe_type, encoding=encoding) + else: + self.pipes[pipe_name] = None + + def has_pipe(self, name): + return name in self.pipes and self.pipes[name] is not None + + def try_delete_pipes(self, pipe_names): + for pipe_name in pipe_names: + if self.has_pipe(pipe_name): + self.pipes[pipe_name].close() + self.pipes[pipe_name] = None + + def try_create_configs(self, command): + if "{direwolf_config}" in command: + self.direwolf_config_path = "{tmp_dir}/openwebrx_direwolf_{myid}.conf".format( + tmp_dir=self.temporary_directory, myid=id(self) + ) + self.direwolf_config = DirewolfConfig() + self.direwolf_config.wire(self) + file = open(self.direwolf_config_path, "w") + file.write(self.direwolf_config.getConfig(self.is_service)) + file.close() + else: + self.direwolf_config = None + self.direwolf_config_path = None + + def try_delete_configs(self): + if self.direwolf_config is not None: + self.direwolf_config.unwire(self) + self.direwolf_config = None + if self.direwolf_config_path is not None: + try: + os.unlink(self.direwolf_config_path) + except FileNotFoundError: + # result suits our expectations. fine :) + pass + except Exception: + logger.exception("try_delete_configs()") + self.direwolf_config_path = None + + def onConfigChanged(self): + self.restart() + + def start(self): + with self.modification_lock: + if self.running: + return + self.running = True + + command_base = " | ".join(self.chain(self.demodulator)) + + # create control pipes for csdr + self.try_create_pipes(self.pipe_names, command_base) + + # send initial config through the pipes + if self.has_pipe("bpf_pipe"): + self.set_bpf(self.low_cut, self.high_cut) + if self.has_pipe("shift_pipe"): + self.set_offset_freq(self.offset_freq) + if self.has_pipe("squelch_pipe"): + self.set_squelch_level(self.squelch_level) + if self.has_pipe("dmr_control_pipe"): + self.set_dmr_filter(3) + + # run the command + command = command_base.format( + bpf_pipe=self.pipes["bpf_pipe"], + shift_pipe=self.pipes["shift_pipe"], + squelch_pipe=self.pipes["squelch_pipe"], + smeter_pipe=self.pipes["smeter_pipe"], + meta_pipe=self.pipes["meta_pipe"], + iqtee_pipe=self.pipes["iqtee_pipe"], + iqtee2_pipe=self.pipes["iqtee2_pipe"], + dmr_control_pipe=self.pipes["dmr_control_pipe"], + decimation=self.decimation, + last_decimation=self.last_decimation, + fft_size=self.fft_size, + fft_block_size=self.fft_block_size(), + fft_averages=self.fft_averages, + bpf_transition_bw=float(self.bpf_transition_bw) / self.if_samp_rate(), + ddc_transition_bw=self.ddc_transition_bw(), + flowcontrol=int(self.samp_rate * 2), + start_bufsize=self.base_bufsize * self.decimation, + nc_port=self.nc_port, + output_rate=self.get_output_rate(), + smeter_report_every=int(self.if_samp_rate() / 6000), + codecserver_arg=self.get_codecserver_arg(), + audio_rate=self.get_audio_rate(), + wfm_deemphasis_tau=self.wfm_deemphasis_tau, + ) + + logger.debug("Command = %s", command) + + out = subprocess.PIPE if self.output.supports_type("audio") else subprocess.DEVNULL + self.process = subprocess.Popen(command, stdout=out, shell=True, start_new_session=True) + + def watch_thread(): + rc = self.process.wait() + logger.debug("dsp thread ended with rc=%d", rc) + if rc == 0 and self.running and not self.modification_lock.locked(): + logger.debug("restarting since rc = 0, self.running = true, and no modification") + self.restart() + + threading.Thread(target=watch_thread, name="csdr_watch_thread").start() + + audio_type = "hd_audio" if self.isHdAudio() else "audio" + if self.output.supports_type(audio_type): + self.output.send_output( + audio_type, + partial( + self.process.stdout.read, + self.get_fft_bytes_to_read() if self.demodulator == "fft" else self.get_audio_bytes_to_read(), + ), + ) + + self.start_secondary_demodulator() + + if self.has_pipe("smeter_pipe"): + + def read_smeter(): + raw = self.pipes["smeter_pipe"].readline() + if len(raw) == 0: + return None + else: + return float(raw.rstrip("\n")) + + self.output.send_output("smeter", read_smeter) + if self.has_pipe("meta_pipe"): + + def read_meta(): + raw = self.pipes["meta_pipe"].readline() + if len(raw) == 0: + return None + else: + return raw.rstrip("\n") + + self.output.send_output("meta", read_meta) + + def stop(self): + with self.modification_lock: + self.running = False + if self.process is not None: + try: + os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + # drain any leftover data to free file descriptors + self.process.communicate() + self.process = None + except ProcessLookupError: + # been killed by something else, ignore + pass + self.stop_secondary_demodulator() + + self.try_delete_pipes(self.pipe_names) + self.try_delete_configs() + + def restart(self): + if not self.running: + return + self.stop() + self.start() diff --git a/openwebrx/csdr/__pycache__/__init__.cpython-37.pyc b/openwebrx/csdr/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..d1fed4e Binary files /dev/null and b/openwebrx/csdr/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/csdr/__pycache__/output.cpython-37.pyc b/openwebrx/csdr/__pycache__/output.cpython-37.pyc new file mode 100644 index 0000000..6bc9702 Binary files /dev/null and b/openwebrx/csdr/__pycache__/output.cpython-37.pyc differ diff --git a/openwebrx/csdr/__pycache__/pipe.cpython-37.pyc b/openwebrx/csdr/__pycache__/pipe.cpython-37.pyc new file mode 100644 index 0000000..d993c0a Binary files /dev/null and b/openwebrx/csdr/__pycache__/pipe.cpython-37.pyc differ diff --git a/openwebrx/csdr/output.py b/openwebrx/csdr/output.py new file mode 100644 index 0000000..5fef242 --- /dev/null +++ b/openwebrx/csdr/output.py @@ -0,0 +1,36 @@ +import threading +import logging + +logger = logging.getLogger(__name__) + + +class Output(object): + def send_output(self, t, read_fn): + if not self.supports_type(t): + # TODO rewrite the output mechanism in a way that avoids producing unnecessary data + logger.warning("dumping output of type %s since it is not supported.", t) + threading.Thread(target=self.pump(read_fn, lambda x: None), name="csdr_pump_thread").start() + return + self.receive_output(t, read_fn) + + def receive_output(self, t, read_fn): + pass + + def pump(self, read, write): + def copy(): + run = True + while run: + data = None + try: + data = read() + except ValueError: + pass + if data is None or (isinstance(data, bytes) and len(data) == 0): + run = False + else: + write(data) + + return copy + + def supports_type(self, t): + return True diff --git a/openwebrx/csdr/pipe.py b/openwebrx/csdr/pipe.py new file mode 100644 index 0000000..025e287 --- /dev/null +++ b/openwebrx/csdr/pipe.py @@ -0,0 +1,156 @@ +import os +import select +import time +import threading + +import logging + +logger = logging.getLogger(__name__) + + +class Pipe(object): + READ = "r" + WRITE = "w" + NONE = None + + @staticmethod + def create(path, t, encoding=None): + if t == Pipe.READ: + return ReadingPipe(path, encoding=encoding) + elif t == Pipe.WRITE: + return WritingPipe(path, encoding=encoding) + elif t == Pipe.NONE: + return Pipe(path, None, encoding=encoding) + + def __init__(self, path, direction, encoding=None): + self.doOpen = True + self.path = "{base}_{myid}".format(base=path, myid=id(self)) + self.direction = direction + self.encoding = encoding + self.file = None + os.mkfifo(self.path) + + def open(self): + """ + this method opens the file descriptor with an added O_NONBLOCK flag. This gives us a special behaviour for + FIFOS, when they are not opened by the opposing side: + + - opening a pipe for writing will throw an OSError with errno = 6 (ENXIO). This is handled specially in the + WritingPipe class. + - opening a pipe for reading will pass through this method instantly, even if the opposing end has not been + opened yet, but the resulting file descriptor will behave as if O_NONBLOCK is set (even if we remove it + immediately here), resulting in empty reads until data is available. This is handled specially in the + ReadingPipe class. + """ + + def opener(path, flags): + fd = os.open(path, flags | os.O_NONBLOCK) + os.set_blocking(fd, True) + return fd + + self.file = open(self.path, self.direction, encoding=self.encoding, opener=opener) + + def close(self): + self.doOpen = False + try: + if self.file is not None: + self.file.close() + os.unlink(self.path) + except FileNotFoundError: + # it seems like we keep calling this twice. no idea why, but we don't need the resulting error. + pass + except Exception: + logger.exception("Pipe.close()") + + def __str__(self): + return self.path + + +class WritingPipe(Pipe): + def __init__(self, path, encoding=None): + self.queue = [] + self.queueLock = threading.Lock() + super().__init__(path, "w", encoding=encoding) + self.open() + + def open_and_dequeue(self): + """ + This method implements a retry loop that can be interrupted in case the Pipe gets shutdown before actually + being connected. + + After the pipe is opened successfully, all data that has been queued is sent in the order it was passed into + write(). + """ + retries = 0 + + while self.file is None and self.doOpen and retries < 10: + try: + super().open() + except OSError as error: + # ENXIO = FIFO has not been opened for reading + if error.errno == 6: + time.sleep(0.1) + retries += 1 + else: + raise + + # if doOpen is false, opening has been canceled, so no warning in that case. + if self.file is None: + if self.doOpen: + logger.warning("could not open FIFO %s", self.path) + return + + with self.queueLock: + for i in self.queue: + self.file.write(i) + self.file.flush() + self.queue = None + + def open(self): + """ + This sends the opening operation off to a background thread. If we were to block the thread here, another pipe + may be waiting in the queue to be opened on the opposing side, resulting in a deadlock + """ + threading.Thread(target=self.open_and_dequeue, name="csdr_pipe_thread").start() + + def write(self, data): + """ + This method queues all data to be written until the file is actually opened. As soon as a file is available, + it becomes a passthrough. + """ + if self.file is None: + with self.queueLock: + self.queue.append(data) + return + r = self.file.write(data) + self.file.flush() + return r + + +class ReadingPipe(Pipe): + def __init__(self, path, encoding=None): + super().__init__(path, "r", encoding=encoding) + + def open(self): + """ + This method implements an interruptible loop that waits for the file descriptor to be opened and the first + batch of data coming in using repeated select() calls. + :return: + """ + if not self.doOpen: + return + super().open() + while self.doOpen: + (read, _, _) = select.select([self.file], [], [], 1) + if self.file in read: + break + + def read(self): + if self.file is None: + self.open() + return self.file.read() + + def readline(self): + if self.file is None: + self.open() + return self.file.readline() diff --git a/openwebrx/debian/changelog b/openwebrx/debian/changelog new file mode 100644 index 0000000..fda5286 --- /dev/null +++ b/openwebrx/debian/changelog @@ -0,0 +1,217 @@ +openwebrx (1.1.0) buster hirsute; urgency=low + + * Reworked most graphical elements as SVGs for faster loadtimes and crispier + display on hi-dpi displays + * Updated pipelines to match changes in digiham + * Changed D-Star and NXDN integrations to use new decoder from digiham + * Added D-Star and NXDN metadata display + + -- Jakob Ketterl Mon, 02 Aug 2021 16:24:00 +0000 + +openwebrx (1.0.0) buster hirsute; urgency=low + * Introduced `squelch_auto_margin` config option that allows configuring the + auto squelch level + * Removed `port` configuration option; `rtltcp_compat` takes the port number + with the new connectors + * Added support for new WSJT-X modes FST4, FST4W (only available with WSJT-X + 2.3) and Q65 (only available with WSJT-X 2.4) + * Added support for demodulating M17 digital voice signals using + m17-cxx-demod + * New reporting infrastructure, allowing WSPR and FST4W spots to be sent to + wsprnet.org + * Add some basic filtering capabilities to the map + * New arguments to the `openwebrx` command-line to facilitate the + administration of users (try `openwebrx admin`) + * New command-line tool `openwebrx-admin` that facilitates the + administration of users + * Default bandwidth changes: + - "WFM" changed to 150kHz + - "Packet" (APRS) changed to 12.5kHz + * Configuration rework: + - New: fully web-based configuration interface + - System configuration parameters have been moved to a new, separate + `openwebrx.conf` file + - Remaining parameters are now editable in the web configuration + - Existing `config_webrx.py` files will still be read, but changes made in + the web configuration will be written to a new storage system + - Added upload of avatar and panorama image via web configuration + * New devices supported: + - HPSDR devices (Hermes Lite 2) thanks to @jancona + - BBRF103 / RX666 / RX888 devices supported by libsddc + - R&S devices using the EB200 or Ammos protocols + + -- Jakob Ketterl Thu, 06 May 2021 17:22:00 +0000 + +openwebrx (0.20.3) buster focal; urgency=low + + * Fix a compatibility issue with python versions <= 3.6 + + -- Jakob Ketterl Tue, 26 Jan 2021 15:28:00 +0000 + +openwebrx (0.20.2) buster focal; urgency=high + + * Fix a security problem that allowed arbitrary commands to be executed on + the receiver (See github issue #215: + https://github.com/jketterl/openwebrx/issues/215) + + -- Jakob Ketterl Sun, 24 Jan 2021 22:50:00 +0000 + +openwebrx (0.20.1) buster focal; urgency=low + + * Remove broken OSM map fallback + + -- Jakob Ketterl Mon, 30 Nov 2020 17:29:00 +0000 + +openwebrx (0.20.0) buster focal; urgency=low + + * Added the ability to sign multiple keys in a single request, thus enabling + multiple users to claim a single receiver on receiverbook.de + * Fixed file descriptor leaks to prevent "too many open files" errors + * Add new demodulator chain for FreeDV + * Added new HD audio streaming mode along with a new WFM demodulator + * Reworked AGC code for better results in AM, SSB and digital modes + * Added support for demodulation of "Digital Radio Mondiale" (DRM) broadcast + using the "dream" decoder. + * New default waterfall color scheme + * Prototype of a continuous automatic waterfall calibration mode + * New devices supported: + - FunCube Dongle Pro+ (`"type": "fcdpp"`) + - Support for connections to rtl_tcp (`"type": "rtl_tcp"`) + + -- Jakob Ketterl Sun, 11 Oct 2020 13:02:00 +0000 + +openwebrx (0.19.1) buster focal; urgency=low + + * Added ability to authenticate receivers with listing sites using + "receiver id" tokens + + -- Jakob Ketterl Sat, 13 Jun 2020 16:46:00 +0000 + +openwebrx (0.19.0) buster focal; urgency=low + * Fix direwolf connection setup by implementing a retry loop + * Pass direct sampling mode changes for rtl_sdr_soapy to owrx_connector + * OSM maps instead of Google when google_maps_api_key is not set (thanks + @jquagga) + * Improved logic to pass parameters to soapy devices. + - `rtl_sdr_soapy`: added support for `bias_tee` + - `sdrplay`: added support for `bias_tee`, `rf_notch` and `dab_notch` + - `airspy`: added support for `bitpack` + * Added support for Perseus-SDR devices, (thanks @amontefusco) + * Property System has been rewritten so that defaults on sdr behave as + expected + * Waterfall range auto-adjustment now only takes the center 80% of the + spectrum into account, which should work better with SDRs that oversample + or have rather flat filter curves towards the spectrum edges + * Bugfix for negative network usage + * FiFi SDR: prevent arecord from shutting down after 2GB of data has been + sent + * Added support for bias tee control on rtl_sdr devices + * All connector driven SDRs now support `"rf_gain": "auto"` to enable AGC + * `rtl_sdr` type now also supports the `direct_sampling` option + * Added decoding implementation for for digimode "JS8Call" (requires an + installation of js8call and the js8py library) + * Reorganization of the frontend demodulator code + * Improve receiver load time by concatenating javascript assets + * HackRF support is now based on SoapyHackRF + * Removed sdr.hu server listing support since the site has been shut down + * Added support for Radioberry 2 Rasbperry Pi SDR Cape + + -- Jakob Ketterl Mon, 01 Jun 2020 17:02:00 +0000 + +openwebrx (0.18.0) buster; urgency=low + + * Compression, resampling and filtering in the frontend have been rewritten + in javascript, sdr.js has been removed + * Decoding of Pocsag modulation is now possible + * Removed the 3D waterfall since it had no real application and required ~1MB + of javascript code to be downloaded + * Improved the frontend handling of the "too many users" scenario + * PSK63 digimode is now available (same decoding pipeline as PSK31, but with + adopted parameters) + * The frequency can now be manipulated with the mousewheel, which should + allow the user to tune more precise. The tuning step size is determined by + the digit the mouse cursor is hovering over. + * Clicking on the frequency now opens an input for direct frequency selection + * URL hashes have been fixed and improved: They are now updated + automatically, so a shared URL will include frequency and demodulator, + which allows for improved sharing and linking. + * New daylight scheduler for background decoding, allows profiles to be + selected by local sunrise / sunset times + * The owrx_connector is now the default way of communicating with sdr + devices. The old sdr types have been replaced, all `_connector` suffixes on + the type must be removed! + * The sources have been refactored, making it a lot easier to add support for + other devices + * SDR device failure handling has been improved, including user feedback + * New devices supported: + * wsjt-x updated to 2.1.2 + * The rtl_tcp compatibility mode of the owrx_connector is now configurable + using the `rtltcp_compat` flag + * explicit device filter for soapy devices for multi-device setups + * compatibility fixes for safari browsers (ios and mac) + * Offset tuning using the `lfo_offset` has been reworked in a way that + `center_freq` has to be set to the frequency you actually want to listen + to. If you're using an `lfo_offset` already, you will probably need to + change its sign. + * `initial_squelch_level` can now be set on each profile. + * Part of the frontend code has been reworked + - Audio buffer minimums have been completely stripped. As a result, you + should get better latency. Unfortunately, this also means there will be + some skipping when audio starts. + - Now also supports AudioWorklets (for those browser that have it). + - Mousewheel controls for the receiver sliders + * Error handling for failed SDR devices + * One of the most-requested features is finally coming to OpenWebRX: + Bookmarks (sometimes also referred to as labels). + There's two kinds of bookmarks available: + - Serverside bookmarks that are set up by the receiver administrator. + Check the file `bookmarks.json` for examples! + - Clientside bookmarks which every user can store for themselves. They are + stored in the browser's localStorage. + * Automatic reporting of spots to [pskreporter](https://pskreporter.info/) is + now possible. Please have a look at the configuration on how to set it up. + * Websocket communication has been overhauled in large parts. It should now + be more reliable, and failing connections should now have no impact on + other users. + * Profile scheduling allows to set up band-hopping if you are running + background services. + * APRS now has the ability to show symbols on the map, if a corresponding + symbol set has been installed. Check the config! + * Debug logging has been disabled in a handful of modules, expect vastly + reduced output on the shell. + * New set of APRS-related features + - Decode Packet transmissions using direwolf (1k2 only for now) + - APRS packets are mostly decoded and shown both in a new panel and on the + map + - APRS is also available as a background service + - direwolfs I-gate functionality can be enabled, which allows your receiver + to work as a receive-only I-gate for the APRS network in the background + * Demodulation for background services has been optimized to use less total + bandwidth, saving CPU + * More metrics have been added; they can be used together with collectd and + its curl_json plugin for now, with some limitations. + * New bandplan feature, the first thing visible is the "dial" indicator that + brings you right to the dial frequency for digital modes + * fixed some bugs in the websocket communication which broke the map + * WSJT-X integration (FT8, FT4, WSPR, JT65, JT9 using wsjt-x demodulators) + * New Map Feature that shows both decoded grid squares from FT8 and Locations + decoded from YSF digital voice + * New Feature report that will show what functionality is available + * major rework on the openwebrx core + * Support of multiple SDR devices simultaneously + * Support for multiple profiles per SDR that allow the user to listen to + different frequencies + * Support for digital voice decoding + * Feature detection that will disable functionality when dependencies are not + available (if you're missing the digital + buttons, this is probably why) + * Support added for the following SDR sources: + - LimeSDR (`"type": "lime_sdr"`) + - PlutoSDR (`"type": "pluto_sdr"`) + - RTL_SDR via Soapy (`"type": "rtl_sdr_soapy"`) on special request to allow + use of the direct sampling mode + - SoapyRemote (`"type": "soapy_remote"`) + - FiFiSDR (`"type": "fifi_sdr"`) + - airspyhf devices (Airspy HF+ / Discovery) (`"type": "airspyhf"`) + + -- Jakob Ketterl Tue, 18 Feb 2020 20:09:00 +0000 diff --git a/openwebrx/debian/compat b/openwebrx/debian/compat new file mode 100644 index 0000000..f599e28 --- /dev/null +++ b/openwebrx/debian/compat @@ -0,0 +1 @@ +10 diff --git a/openwebrx/debian/control b/openwebrx/debian/control new file mode 100644 index 0000000..6b274ba --- /dev/null +++ b/openwebrx/debian/control @@ -0,0 +1,16 @@ +Source: openwebrx +Maintainer: Jakob Ketterl +Section: hamradio +Priority: optional +Standards-Version: 4.2.0 +Build-Depends: debhelper (>= 11), dh-python, python3-all (>= 3.5), python3-setuptools +Homepage: https://www.openwebrx.de/ +Vcs-Browser: https://github.com/jketterl/openwebrx +Vcs-Git: https://github.com/jketterl/openwebrx.git + +Package: openwebrx +Architecture: all +Depends: adduser, python3 (>= 3.5), python3-pkg-resources, csdr (>= 0.17), netcat, owrx-connector (>= 0.5), soapysdr-tools, python3-js8py (>= 0.1), ${python3:Depends}, ${misc:Depends} +Recommends: digiham (>= 0.5), sox, direwolf (>= 1.4), wsjtx, runds-connector (>= 0.2), hpsdrconnector, aprs-symbols, m17-demod, js8call +Description: multi-user web sdr + Open source, multi-user SDR receiver with a web interface diff --git a/openwebrx/debian/openwebrx.config b/openwebrx/debian/openwebrx.config new file mode 100644 index 0000000..9a19393 --- /dev/null +++ b/openwebrx/debian/openwebrx.config @@ -0,0 +1,8 @@ +#!/bin/sh -e +. /usr/share/debconf/confmodule + +db_get openwebrx/admin_user_configured +if [ "${1:-}" = "reconfigure" ] || [ "${RET}" != true ]; then + db_input high openwebrx/admin_user_password || true + db_go +fi diff --git a/openwebrx/debian/openwebrx.dirs b/openwebrx/debian/openwebrx.dirs new file mode 100644 index 0000000..c87b1b2 --- /dev/null +++ b/openwebrx/debian/openwebrx.dirs @@ -0,0 +1 @@ +/etc/openwebrx/openwebrx.conf.d \ No newline at end of file diff --git a/openwebrx/debian/openwebrx.install b/openwebrx/debian/openwebrx.install new file mode 100644 index 0000000..8db9344 --- /dev/null +++ b/openwebrx/debian/openwebrx.install @@ -0,0 +1,3 @@ +bands.json etc/openwebrx/ +openwebrx.conf etc/openwebrx/ +systemd/openwebrx.service lib/systemd/system/ \ No newline at end of file diff --git a/openwebrx/debian/openwebrx.postinst b/openwebrx/debian/openwebrx.postinst new file mode 100644 index 0000000..935a0fe --- /dev/null +++ b/openwebrx/debian/openwebrx.postinst @@ -0,0 +1,59 @@ +#!/bin/bash +. /usr/share/debconf/confmodule + +set -euo pipefail + +OWRX_USER="openwebrx" +OWRX_DATADIR="/var/lib/openwebrx" +OWRX_USERS_FILE="${OWRX_DATADIR}/users.json" +OWRX_SETTINGS_FILE="${OWRX_DATADIR}/settings.json" +OWRX_BOOKMARKS_FILE="${OWRX_DATADIR}/bookmarks.json" + +case "$1" in + configure|reconfigure) + adduser --system --group --no-create-home --home /nonexistent --quiet "${OWRX_USER}" + usermod -aG plugdev "${OWRX_USER}" + + # create OpenWebRX data directory and set the correct permissions + if [ ! -d "${OWRX_DATADIR}" ] && [ ! -L "${OWRX_DATADIR}" ]; then mkdir "${OWRX_DATADIR}"; fi + chown "${OWRX_USER}". ${OWRX_DATADIR} + + # create empty config files now to avoid permission problems later + if [ ! -e "${OWRX_USERS_FILE}" ]; then + echo "[]" > "${OWRX_USERS_FILE}" + chown "${OWRX_USER}". "${OWRX_USERS_FILE}" + chmod 0600 "${OWRX_USERS_FILE}" + fi + + if [ ! -e "${OWRX_SETTINGS_FILE}" ]; then + echo "{}" > "${OWRX_SETTINGS_FILE}" + chown "${OWRX_USER}". "${OWRX_SETTINGS_FILE}" + fi + + if [ ! -e "${OWRX_BOOKMARKS_FILE}" ]; then + touch "${OWRX_BOOKMARKS_FILE}" + chown "${OWRX_USER}". "${OWRX_BOOKMARKS_FILE}" + fi + + db_get openwebrx/admin_user_password + if [ ! -z "${RET}" ]; then + if ! openwebrx admin --silent hasuser admin; then + # create initial openwebrx user + OWRX_PASSWORD="${RET}" openwebrx admin --noninteractive adduser admin + else + # change existing user's password + OWRX_PASSWORD="${RET}" openwebrx admin --noninteractive resetpassword admin + fi + fi + # remove password from debconf database + db_unregister openwebrx/admin_user_password + # set a marker that admin is configured to avoid future questions + db_set openwebrx/admin_user_configured true + ;; + *) + echo "postinst called with unknown argument '$1'" 1>&2 + exit 1 + ;; +esac + +#DEBHELPER# diff --git a/openwebrx/debian/openwebrx.postrm b/openwebrx/debian/openwebrx.postrm new file mode 100644 index 0000000..9260b8e --- /dev/null +++ b/openwebrx/debian/openwebrx.postrm @@ -0,0 +1,8 @@ +#!/bin/sh -e + +if [ "$1" = purge ] && [ -e /usr/share/debconf/confmodule ]; then + . /usr/share/debconf/confmodule + db_purge +fi + +#DEBHELPER# diff --git a/openwebrx/debian/openwebrx.templates b/openwebrx/debian/openwebrx.templates new file mode 100644 index 0000000..8ef7e84 --- /dev/null +++ b/openwebrx/debian/openwebrx.templates @@ -0,0 +1,23 @@ +Template: openwebrx/admin_user_password +Type: password +Description: OpenWebRX "admin" user password: + The system can create a user for the OpenWebRX web configuration interface for + you. Using this user, you will be able to log into the "settings" area of + OpenWebRX to configure your receiver conveniently through your browser. + . + The name of the created user will be "admin". + . + If you do not wish to create a web admin user right now, you can leave this + empty for now. You can return to this prompt at a later time by running the + command "sudo dpkg-reconfigure openwebrx". + . + You can also use the "openwebrx admin" command to create, delete or manage + existing users. More information is available in by running the command + "openwebrx admin --help". + +Template: openwebrx/admin_user_configured +Type: boolean +Default: false +Description: OpenWebRX "admin" user previously configured? + Marker used internally by the config scripts to remember if an admin user has + been created. \ No newline at end of file diff --git a/openwebrx/debian/rules b/openwebrx/debian/rules new file mode 100644 index 0000000..3b7418e --- /dev/null +++ b/openwebrx/debian/rules @@ -0,0 +1,8 @@ +#!/usr/bin/make -f +export PYBUILD_NAME=openwebrx + +%: + dh $@ --with python3 --buildsystem=pybuild --with systemd + +override_dh_strip_nondeterminism: + dh_strip_nondeterminism -X.png diff --git a/openwebrx/debian/source/format b/openwebrx/debian/source/format new file mode 100644 index 0000000..9f67427 --- /dev/null +++ b/openwebrx/debian/source/format @@ -0,0 +1 @@ +3.0 (native) \ No newline at end of file diff --git a/openwebrx/docker.sh b/openwebrx/docker.sh new file mode 100644 index 0000000..c2bfe7f --- /dev/null +++ b/openwebrx/docker.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARCH=$(uname -m) +IMAGES="openwebrx-rtlsdr openwebrx-sdrplay openwebrx-hackrf openwebrx-airspy openwebrx-rtlsdr-soapy openwebrx-plutosdr openwebrx-limesdr openwebrx-soapyremote openwebrx-perseus openwebrx-fcdpp openwebrx-radioberry openwebrx-uhd openwebrx-rtltcp openwebrx-runds openwebrx-hpsdr openwebrx-full openwebrx" +ALL_ARCHS="x86_64 armv7l aarch64" +TAG=${TAG:-"latest"} +ARCHTAG="${TAG}-${ARCH}" + +usage () { + echo "Usage: ${0} [command]" + echo "Available commands:" + echo " help Show this usage information" + echo " build Build all docker images" + echo " push Push built docker images to the docker hub" + echo " manifest Compile the docker hub manifest (combines arm and x86 tags into one)" + echo " tag Tag a release" +} + +build () { + # build the base images + docker build --pull -t openwebrx-base:${ARCHTAG} -f docker/Dockerfiles/Dockerfile-base . + docker build --build-arg ARCHTAG=${ARCHTAG} -t openwebrx-soapysdr-base:${ARCHTAG} -f docker/Dockerfiles/Dockerfile-soapysdr . + + for image in ${IMAGES}; do + i=${image:10} + # "openwebrx" is a special image that gets tag-aliased later on + if [[ ! -z "${i}" ]] ; then + docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/${image}:${ARCHTAG} -f docker/Dockerfiles/Dockerfile-${i} . + fi + done + + # tag openwebrx alias image + docker tag jketterl/openwebrx-full:${ARCHTAG} jketterl/openwebrx:${ARCHTAG} +} + +push () { + for image in ${IMAGES}; do + docker push jketterl/${image}:${ARCHTAG} + done +} + +manifest () { + for image in ${IMAGES}; do + # there's no docker manifest rm command, and the create --amend does not work, so we have to clean up manually + rm -rf "${HOME}/.docker/manifests/docker.io_jketterl_${image}-${TAG}" + IMAGE_LIST="" + for a in ${ALL_ARCHS}; do + IMAGE_LIST="${IMAGE_LIST} jketterl/${image}:${TAG}-${a}" + done + docker manifest create jketterl/${image}:${TAG} ${IMAGE_LIST} + docker manifest push --purge jketterl/${image}:${TAG} + done +} + +tag () { + if [[ -x ${1:-} || -z ${2:-} ]] ; then + echo "Usage: ${0} tag [SRC_TAG] [TARGET_TAG]" + return + fi + + local SRC_TAG=${1} + local TARGET_TAG=${2} + + for image in ${IMAGES}; do + # there's no docker manifest rm command, and the create --amend does not work, so we have to clean up manually + rm -rf "${HOME}/.docker/manifests/docker.io_jketterl_${image}-${TARGET_TAG}" + IMAGE_LIST="" + for a in ${ALL_ARCHS}; do + docker pull jketterl/${image}:${SRC_TAG}-${a} + docker tag jketterl/${image}:${SRC_TAG}-${a} jketterl/${image}:${TARGET_TAG}-${a} + docker push jketterl/${image}:${TARGET_TAG}-${a} + IMAGE_LIST="${IMAGE_LIST} jketterl/${image}:${TARGET_TAG}-${a}" + done + docker manifest create jketterl/${image}:${TARGET_TAG} ${IMAGE_LIST} + docker manifest push --purge jketterl/${image}:${TARGET_TAG} + docker pull jketterl/${image}:${TARGET_TAG} + done +} + +case ${1:-} in + build) + build + ;; + push) + push + ;; + manifest) + manifest + ;; + tag) + tag ${@:2} + ;; + *) + usage + ;; +esac \ No newline at end of file diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-airspy b/openwebrx/docker/Dockerfiles/Dockerfile-airspy new file mode 100644 index 0000000..94b348b --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-airspy @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-airspy.sh / +RUN /install-dependencies-airspy.sh &&\ + rm /install-dependencies-airspy.sh + +ADD . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-base b/openwebrx/docker/Dockerfiles/Dockerfile-base new file mode 100644 index 0000000..6fd184f --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-base @@ -0,0 +1,28 @@ +FROM debian:buster-slim + +COPY docker/files/js8call/js8call-hamlib.patch \ + docker/files/wsjtx/wsjtx.patch \ + docker/files/wsjtx/wsjtx-hamlib.patch \ + docker/files/dream/dream.patch \ + docker/files/direwolf/direwolf-hamlib.patch \ + docker/scripts/install-dependencies.sh / +RUN /install-dependencies.sh && \ + rm /install-dependencies.sh && \ + rm /*.patch +COPY docker/scripts/install-owrx-tools.sh / +RUN /install-owrx-tools.sh && \ + rm /install-owrx-tools.sh + +COPY docker/files/services/codecserver /etc/services.d/codecserver + +ENTRYPOINT ["/init"] + +WORKDIR /opt/openwebrx + +VOLUME /etc/openwebrx +VOLUME /var/lib/openwebrx + +ENV S6_CMD_ARG0="/opt/openwebrx/docker/scripts/run.sh" +CMD [] + +EXPOSE 8073 diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-fcdpp b/openwebrx/docker/Dockerfiles/Dockerfile-fcdpp new file mode 100644 index 0000000..3e28ac7 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-fcdpp @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-fcdpp.sh / +RUN /install-dependencies-fcdpp.sh &&\ + rm /install-dependencies-fcdpp.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-full b/openwebrx/docker/Dockerfiles/Dockerfile-full new file mode 100644 index 0000000..cecf45a --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-full @@ -0,0 +1,30 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-*.sh \ + docker/files/sdrplay/install-lib.*.patch \ + docker/scripts/install-connectors.sh / + +RUN /install-dependencies-rtlsdr.sh &&\ + /install-dependencies-soapysdr.sh &&\ + /install-dependencies-hackrf.sh &&\ + /install-dependencies-sdrplay.sh &&\ + /install-dependencies-airspy.sh &&\ + /install-dependencies-rtlsdr-soapy.sh &&\ + /install-dependencies-plutosdr.sh &&\ + /install-dependencies-limesdr.sh &&\ + /install-dependencies-soapyremote.sh &&\ + /install-dependencies-perseus.sh &&\ + /install-dependencies-fcdpp.sh &&\ + /install-dependencies-radioberry.sh &&\ + /install-dependencies-uhd.sh &&\ + /install-dependencies-hpsdr.sh &&\ + /install-connectors.sh &&\ + /install-dependencies-runds.sh &&\ + rm /install-dependencies-*.sh &&\ + rm /install-lib.*.patch && \ + rm /install-connectors.sh + +COPY docker/files/services/sdrplay /etc/services.d/sdrplay + +ADD . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-hackrf b/openwebrx/docker/Dockerfiles/Dockerfile-hackrf new file mode 100644 index 0000000..6dab0f1 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-hackrf @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-hackrf.sh / +RUN /install-dependencies-hackrf.sh &&\ + rm /install-dependencies-hackrf.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-hpsdr b/openwebrx/docker/Dockerfiles/Dockerfile-hpsdr new file mode 100644 index 0000000..96d58b9 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-hpsdr @@ -0,0 +1,9 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-hpsdr.sh / + +RUN /install-dependencies-hpsdr.sh &&\ + rm /install-dependencies-hpsdr.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-limesdr b/openwebrx/docker/Dockerfiles/Dockerfile-limesdr new file mode 100644 index 0000000..9603c60 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-limesdr @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-limesdr.sh / +RUN /install-dependencies-limesdr.sh &&\ + rm /install-dependencies-limesdr.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-perseus b/openwebrx/docker/Dockerfiles/Dockerfile-perseus new file mode 100644 index 0000000..bc16583 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-perseus @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-perseus.sh / +RUN /install-dependencies-perseus.sh &&\ + rm /install-dependencies-perseus.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-plutosdr b/openwebrx/docker/Dockerfiles/Dockerfile-plutosdr new file mode 100644 index 0000000..4a263e8 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-plutosdr @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-plutosdr.sh / +RUN /install-dependencies-plutosdr.sh &&\ + rm /install-dependencies-plutosdr.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-radioberry b/openwebrx/docker/Dockerfiles/Dockerfile-radioberry new file mode 100644 index 0000000..3cbe978 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-radioberry @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-radioberry.sh / +RUN /install-dependencies-radioberry.sh &&\ + rm /install-dependencies-radioberry.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr b/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr new file mode 100644 index 0000000..6144641 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr @@ -0,0 +1,12 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-rtlsdr.sh \ + docker/scripts/install-connectors.sh / + +RUN /install-dependencies-rtlsdr.sh &&\ + rm /install-dependencies-rtlsdr.sh &&\ + /install-connectors.sh &&\ + rm /install-connectors.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr-soapy b/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr-soapy new file mode 100644 index 0000000..5dce90f --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr-soapy @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-rtlsdr-soapy.sh / +RUN /install-dependencies-rtlsdr-soapy.sh &&\ + rm /install-dependencies-rtlsdr-soapy.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-rtltcp b/openwebrx/docker/Dockerfiles/Dockerfile-rtltcp new file mode 100644 index 0000000..240799d --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-rtltcp @@ -0,0 +1,9 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-connectors.sh / + +RUN /install-connectors.sh &&\ + rm /install-connectors.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-runds b/openwebrx/docker/Dockerfiles/Dockerfile-runds new file mode 100644 index 0000000..2a087e1 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-runds @@ -0,0 +1,12 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-connectors.sh \ + docker/scripts/install-dependencies-runds.sh / + +RUN /install-connectors.sh &&\ + rm /install-connectors.sh && \ + /install-dependencies-runds.sh && \ + rm /install-dependencies-runds.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-sdrplay b/openwebrx/docker/Dockerfiles/Dockerfile-sdrplay new file mode 100644 index 0000000..bb53d7e --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-sdrplay @@ -0,0 +1,12 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-sdrplay.sh \ + docker/files/sdrplay/install-lib.*.patch / +RUN /install-dependencies-sdrplay.sh &&\ + rm /install-dependencies-sdrplay.sh &&\ + rm /install-lib.*.patch + +COPY docker/files/services/sdrplay /etc/services.d/sdrplay + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-soapyremote b/openwebrx/docker/Dockerfiles/Dockerfile-soapyremote new file mode 100644 index 0000000..e5c207c --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-soapyremote @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-soapyremote.sh / +RUN /install-dependencies-soapyremote.sh &&\ + rm /install-dependencies-soapyremote.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-soapysdr b/openwebrx/docker/Dockerfiles/Dockerfile-soapysdr new file mode 100644 index 0000000..45ac693 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-soapysdr @@ -0,0 +1,9 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-soapysdr.sh \ + docker/scripts/install-connectors.sh / +RUN /install-dependencies-soapysdr.sh &&\ + rm /install-dependencies-soapysdr.sh &&\ + /install-connectors.sh &&\ + rm /install-connectors.sh diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-uhd b/openwebrx/docker/Dockerfiles/Dockerfile-uhd new file mode 100644 index 0000000..ae1e758 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-uhd @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-uhd.sh / +RUN /install-dependencies-uhd.sh &&\ + rm /install-dependencies-uhd.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/files/direwolf/direwolf-hamlib.patch b/openwebrx/docker/files/direwolf/direwolf-hamlib.patch new file mode 100644 index 0000000..2347c24 --- /dev/null +++ b/openwebrx/docker/files/direwolf/direwolf-hamlib.patch @@ -0,0 +1,20 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 9e710f5..da90b43 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -257,13 +257,8 @@ else() + set(GPSD_LIBRARIES "") + endif() + +-find_package(hamlib) +-if(HAMLIB_FOUND) +- set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DUSE_HAMLIB") +-else() +- set(HAMLIB_INCLUDE_DIRS "") +- set(HAMLIB_LIBRARIES "") +-endif() ++set(HAMLIB_INCLUDE_DIRS "") ++set(HAMLIB_LIBRARIES "") + + if(LINUX) + find_package(ALSA REQUIRED) diff --git a/openwebrx/docker/files/dream/dream.patch b/openwebrx/docker/files/dream/dream.patch new file mode 100644 index 0000000..9a7a8e2 --- /dev/null +++ b/openwebrx/docker/files/dream/dream.patch @@ -0,0 +1,96 @@ +--- dream.pro.org 2020-09-04 22:51:51.579926191 +0200 ++++ dream.pro 2020-09-04 22:52:57.609434707 +0200 +@@ -70,9 +70,6 @@ + exists(/opt/local/include/speex/speex_preprocess.h) { + CONFIG += speexdsp + } +- exists(/opt/local/include/hamlib/rig.h) { +- CONFIG += hamlib +- } + contains(QT_VERSION, ^4\\.7.*) { + QT += phonon opengl svg + DEFINES -= QWT_NO_SVG +@@ -138,12 +135,6 @@ + packagesExist(sndfile) { + CONFIG += sndfile + } +- packagesExist(hamlib) { +- CONFIG += hamlib +- } +- packagesExist(gpsd) { +- CONFIG += gps +- } + packagesExist(pcap) { + CONFIG += pcap + } +@@ -159,14 +150,6 @@ + exists(/usr/local/include/sndfile.h) { + CONFIG += sndfile + } +- exists(/usr/include/hamlib/rig.h) | \ +- exists(/usr/local/include/hamlib/rig.h) { +- CONFIG += hamlib +- } +- exists(/usr/include/gps.h) | \ +- exists(/usr/local/include/gps.h) { +- CONFIG += gps +- } + exists(/usr/include/pcap.h) | \ + exists(/usr/local/include/pcap.h) { + CONFIG += pcap +@@ -194,9 +177,6 @@ + exists($$OUT_PWD/include/speex/speex_preprocess.h) { + CONFIG += speexdsp + } +- exists($$OUT_PWD/include/hamlib/rig.h) { +- CONFIG += hamlib +- } + exists($$OUT_PWD/include/pcap.h) { + CONFIG += pcap + } +@@ -225,7 +205,7 @@ + LIBS += -lz + } + } +-exists($$OUT_PWD/include/neaacdec.h) { ++exists(/usr/include/neaacdec.h) { + DEFINES += HAVE_LIBFAAD \ + USE_FAAD2_LIBRARY + LIBS += -lfaad_drm +@@ -257,11 +237,6 @@ + win32:LIBS += libspeexdsp.lib + message("with libspeexdsp") + } +-gps { +- DEFINES += HAVE_LIBGPS +- unix:LIBS += -lgps +- message("with gps") +-} + pcap { + DEFINES += HAVE_LIBPCAP + unix:LIBS += -lpcap +@@ -269,24 +244,6 @@ + win32-g++:LIBS += -lwpcap -lpacket + message("with pcap") + } +-hamlib { +- DEFINES += HAVE_LIBHAMLIB +- macx:LIBS += -framework IOKit +- unix:LIBS += -lhamlib +- win32:LIBS += libhamlib-2.lib +- HEADERS += src/util/Hamlib.h +- SOURCES += src/util/Hamlib.cpp +- qt { +- HEADERS += src/util-QT/Rig.h +- SOURCES += src/util-QT/Rig.cpp +- } +- gui { +- HEADERS += src/GUI-QT/RigDlg.h +- SOURCES += src/GUI-QT/RigDlg.cpp +- FORMS += RigDlg.ui +- } +- message("with hamlib") +-} + qwt { + DEFINES += QWT_NO_SVG + macx { diff --git a/openwebrx/docker/files/js8call/js8call-hamlib.patch b/openwebrx/docker/files/js8call/js8call-hamlib.patch new file mode 100644 index 0000000..899f83e --- /dev/null +++ b/openwebrx/docker/files/js8call/js8call-hamlib.patch @@ -0,0 +1,151 @@ +diff -ur js8call-orig/CMake/Modules/Findhamlib.cmake js8call/CMake/Modules/Findhamlib.cmake +--- js8call-orig/CMake/Modules/Findhamlib.cmake 2020-07-22 18:14:18.014499840 +0200 ++++ js8call/CMake/Modules/Findhamlib.cmake 2020-07-22 18:16:07.200375473 +0200 +@@ -78,4 +78,4 @@ + # Handle the QUIETLY and REQUIRED arguments and set HAMLIB_FOUND to + # TRUE if all listed variables are TRUE + include (FindPackageHandleStandardArgs) +-find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES hamlib_LIBRARY_DIRS) ++find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES) +diff -ur js8call-orig/CMakeLists.txt js8call/CMakeLists.txt +--- js8call-orig/CMakeLists.txt 2020-07-22 18:14:18.014499840 +0200 ++++ js8call/CMakeLists.txt 2020-07-22 18:17:55.629633825 +0200 +@@ -558,7 +558,7 @@ + # + # libhamlib setup + # +-set (hamlib_STATIC 1) ++set (hamlib_STATIC 0) + find_package (hamlib 3 REQUIRED) + find_program (RIGCTL_EXE rigctl) + find_program (RIGCTLD_EXE rigctld) +@@ -911,56 +911,6 @@ + target_link_libraries (js8 wsjt_fort wsjt_cxx Qt5::Core) + endif (${OPENMP_FOUND} OR APPLE) + +-# build the main application +-add_executable (js8call MACOSX_BUNDLE +- ${sqlite3_CSRCS} +- ${wsjtx_CXXSRCS} +- ${wsjtx_GENUISRCS} +- wsjtx.rc +- ${WSJTX_ICON_FILE} +- ${wsjtx_RESOURCES_RCC} +- images.qrc +- ) +- +-if (WSJT_CREATE_WINMAIN) +- set_target_properties (js8call PROPERTIES WIN32_EXECUTABLE ON) +-endif (WSJT_CREATE_WINMAIN) +- +-set_target_properties (js8call PROPERTIES +- MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Darwin/Info.plist.in" +- MACOSX_BUNDLE_INFO_STRING "${WSJTX_DESCRIPTION_SUMMARY}" +- MACOSX_BUNDLE_ICON_FILE "${WSJTX_ICON_FILE}" +- MACOSX_BUNDLE_BUNDLE_VERSION ${wsjtx_VERSION} +- MACOSX_BUNDLE_SHORT_VERSION_STRING "v${wsjtx_VERSION}" +- MACOSX_BUNDLE_LONG_VERSION_STRING "Version ${wsjtx_VERSION}" +- MACOSX_BUNDLE_BUNDLE_NAME "${PROJECT_NAME}" +- MACOSX_BUNDLE_BUNDLE_EXECUTABLE_NAME "${PROJECT_NAME}" +- MACOSX_BUNDLE_COPYRIGHT "${PROJECT_COPYRIGHT}" +- MACOSX_BUNDLE_GUI_IDENTIFIER "org.kn4crd.js8call" +- ) +- +-target_include_directories (js8call PRIVATE ${FFTW3_INCLUDE_DIRS}) +-if (APPLE) +- target_link_libraries (js8call wsjt_fort wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES}) +-else () +- target_link_libraries (js8call wsjt_fort_omp wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES}) +- if (OpenMP_C_FLAGS) +- set_target_properties (js8call PROPERTIES +- COMPILE_FLAGS "${OpenMP_C_FLAGS}" +- LINK_FLAGS "${OpenMP_C_FLAGS}" +- ) +- endif () +- set_target_properties (js8call PROPERTIES +- Fortran_MODULE_DIRECTORY ${CMAKE_BINARY_DIR}/fortran_modules_omp +- ) +- if (WIN32) +- set_target_properties (js8call PROPERTIES +- LINK_FLAGS -Wl,--stack,16777216 +- ) +- endif () +-endif () +-qt5_use_modules (js8call SerialPort) # not sure why the interface link library syntax above doesn't work +- + # if (UNIX) + # if (NOT WSJT_SKIP_MANPAGES) + # add_subdirectory (manpages) +@@ -976,38 +926,10 @@ + # + # installation + # +-install (TARGETS js8call +- RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime +- BUNDLE DESTINATION . COMPONENT runtime +- ) +- + install (TARGETS js8 RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime + BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime + ) + +-install (PROGRAMS +- ${RIGCTL_EXE} +- DESTINATION ${CMAKE_INSTALL_BINDIR} +- #COMPONENT runtime +- RENAME rigctl-local${CMAKE_EXECUTABLE_SUFFIX} +- ) +- +-install (PROGRAMS +- ${RIGCTLD_EXE} +- DESTINATION ${CMAKE_INSTALL_BINDIR} +- #COMPONENT runtime +- RENAME rigctld-local${CMAKE_EXECUTABLE_SUFFIX} +- ) +- +-install (FILES +- README +- COPYING +- INSTALL +- INSTALL-WSJTX +- DESTINATION ${CMAKE_INSTALL_DOCDIR} +- #COMPONENT runtime +- ) +- + install (FILES + contrib/Ephemeris/JPLEPH + DESTINATION ${CMAKE_INSTALL_DATADIR}/${CMAKE_PROJECT_NAME} +@@ -1061,32 +983,6 @@ + "${CMAKE_CURRENT_BINARY_DIR}/wsjtx_config.h" + ) + +- +-if (NOT WIN32 AND NOT APPLE) +- # install a desktop file so js8call appears in the application start +- # menu with an icon +- install ( +- FILES js8call.desktop +- DESTINATION /usr/share/applications +- #COMPONENT runtime +- ) +- install ( +- FILES icons/Unix/js8call_icon.png +- DESTINATION /usr/share/pixmaps +- #COMPONENT runtime +- ) +- +- IF("${CMAKE_INSTALL_PREFIX}" STREQUAL "/opt/js8call") +- execute_process(COMMAND ln -s /opt/js8call/bin/js8call ljs8call) +- +- install(FILES +- ${CMAKE_BINARY_DIR}/ljs8call DESTINATION /usr/bin/ RENAME js8call +- #COMPONENT runtime +- ) +- endif() +-endif (NOT WIN32 AND NOT APPLE) +- +- + # + # bundle fixup only done in Release or MinSizeRel configurations + # +Only in js8call/: .idea diff --git a/openwebrx/docker/files/sdrplay/install-lib.aarch64.patch b/openwebrx/docker/files/sdrplay/install-lib.aarch64.patch new file mode 100644 index 0000000..1f3dc57 --- /dev/null +++ b/openwebrx/docker/files/sdrplay/install-lib.aarch64.patch @@ -0,0 +1,23 @@ +diff -ur sdrplay-orig/install_lib.sh sdrplay/install_lib.sh +--- sdrplay-orig/install_lib.sh 2020-05-24 14:30:06.022483867 +0000 ++++ sdrplay/install_lib.sh 2020-05-24 14:30:49.093435726 +0000 +@@ -4,19 +4,6 @@ + export MAJVERS="3" + + echo "Installing SDRplay RSP API library ${VERS}..." +-read -p "Press RETURN to view the license agreement" ret +- +-more sdrplay_license.txt +- +-while true; do +- echo "Press y and RETURN to accept the license agreement and continue with" +- read -p "the installation, or press n and RETURN to exit the installer [y/n] " yn +- case $yn in +- [Yy]* ) break;; +- [Nn]* ) exit;; +- * ) echo "Please answer y or n";; +- esac +-done + + export ARCH=`uname -m` + diff --git a/openwebrx/docker/files/sdrplay/install-lib.armv7l.patch b/openwebrx/docker/files/sdrplay/install-lib.armv7l.patch new file mode 100644 index 0000000..22a78f6 --- /dev/null +++ b/openwebrx/docker/files/sdrplay/install-lib.armv7l.patch @@ -0,0 +1,40 @@ +diff -ur sdrplay-orig/install_lib.sh sdrplay/install_lib.sh +--- sdrplay-orig/install_lib.sh 2020-05-24 14:13:04.561271707 +0000 ++++ sdrplay/install_lib.sh 2020-05-24 14:16:20.068329040 +0000 +@@ -4,19 +4,6 @@ + MAJVERS="3" + + echo "Installing SDRplay RSP API library ${VERS}..." +-read -p "Press RETURN to view the license agreement" ret +- +-more sdrplay_license.txt +- +-while true; do +- echo "Press y and RETURN to accept the license agreement and continue with" +- read -p "the installation, or press n and RETURN to exit the installer [y/n] " yn +- case $yn in +- [Yy]* ) break;; +- [Nn]* ) exit;; +- * ) echo "Please answer y or n";; +- esac +-done + + ARCH=`uname -m` + +@@ -141,16 +128,6 @@ + echo "SDRplay API ${VERS} Installation Finished" + echo " " + +-while true; do +- echo "Would you like to add SDRplay USB IDs to the local database for easier +-" +- read -p "identification in applications such as lsusb? [y/n] " yn +- case $yn in +- [Yy]* ) break;; +- [Nn]* ) exit;; +- * ) echo "Please answer y or n";; +- esac +-done + sudo cp scripts/sdrplay_usbids.sh ${INSTALLBINDIR}/. + sudo chmod 755 ${INSTALLBINDIR}/sdrplay_usbids.sh + sudo cp scripts/sdrplay_ids.txt ${INSTALLBINDIR}/. diff --git a/openwebrx/docker/files/sdrplay/install-lib.x86_64.patch b/openwebrx/docker/files/sdrplay/install-lib.x86_64.patch new file mode 100644 index 0000000..d66023b --- /dev/null +++ b/openwebrx/docker/files/sdrplay/install-lib.x86_64.patch @@ -0,0 +1,39 @@ +diff -ur sdrplay-orig/install_lib.sh sdrplay/install_lib.sh +--- sdrplay-orig/install_lib.sh 2020-05-24 13:56:56.622000041 +0000 ++++ sdrplay/install_lib.sh 2020-05-24 13:58:51.837801559 +0000 +@@ -4,19 +4,6 @@ + MAJVERS="3" + + echo "Installing SDRplay RSP API library ${VERS}..." +-read -p "Press RETURN to view the license agreement" ret +- +-more sdrplay_license.txt +- +-while true; do +- echo "Press y and RETURN to accept the license agreement and continue with" +- read -p "the installation, or press n and RETURN to exit the installer [y/n] " yn +- case $yn in +- [Yy]* ) break;; +- [Nn]* ) exit;; +- * ) echo "Please answer y or n";; +- esac +-done + + ARCH=`uname -m` + OSDIST="Unknown" +@@ -157,15 +144,6 @@ + echo " " + echo "SDRplay API ${VERS} Installation Finished" + echo " " +-while true; do +- echo "Would you like to add SDRplay USB IDs to the local database for easier" +- read -p "identification in applications such as lsusb? [y/n] " yn +- case $yn in +- [Yy]* ) break;; +- [Nn]* ) exit;; +- * ) echo "Please answer y or n";; +- esac +-done + sudo cp scripts/sdrplay_usbids.sh ${INSTALLBINDIR}/. + sudo chmod 755 ${INSTALLBINDIR}/sdrplay_usbids.sh + sudo cp scripts/sdrplay_ids.txt ${INSTALLBINDIR}/. diff --git a/openwebrx/docker/files/services/codecserver/run b/openwebrx/docker/files/services/codecserver/run new file mode 100644 index 0000000..43c8212 --- /dev/null +++ b/openwebrx/docker/files/services/codecserver/run @@ -0,0 +1,2 @@ +#!/usr/bin/execlineb -P +/usr/local/bin/codecserver \ No newline at end of file diff --git a/openwebrx/docker/files/services/sdrplay/run b/openwebrx/docker/files/services/sdrplay/run new file mode 100644 index 0000000..0f31c4c --- /dev/null +++ b/openwebrx/docker/files/services/sdrplay/run @@ -0,0 +1,2 @@ +#!/usr/bin/execlineb -P +/usr/local/bin/sdrplay_apiService \ No newline at end of file diff --git a/openwebrx/docker/files/wsjtx/wsjtx-hamlib.patch b/openwebrx/docker/files/wsjtx/wsjtx-hamlib.patch new file mode 100644 index 0000000..47f37d9 --- /dev/null +++ b/openwebrx/docker/files/wsjtx/wsjtx-hamlib.patch @@ -0,0 +1,50 @@ +--- CMakeLists.txt.orig 2021-03-30 15:28:36.956587995 +0200 ++++ CMakeLists.txt 2021-03-30 15:29:45.719326832 +0200 +@@ -106,24 +106,6 @@ + + + # +-# build and install hamlib locally so it can be referenced by the +-# WSJT-X build +-# +-ExternalProject_Add (hamlib +- GIT_REPOSITORY ${hamlib_repo} +- GIT_TAG ${hamlib_TAG} +- GIT_SHALLOW False +- URL ${CMAKE_CURRENT_SOURCE_DIR}/src/${__hamlib_upstream}.tar.gz +- URL_HASH MD5=${hamlib_md5sum} +- #UPDATE_COMMAND ${CMAKE_COMMAND} -E env "[ -f ./bootstrap ] && ./bootstrap" +- PATCH_COMMAND ${PATCH_EXECUTABLE} -p1 -N < ${CMAKE_CURRENT_SOURCE_DIR}/hamlib.patch +- CONFIGURE_COMMAND /configure --prefix= --disable-shared --enable-static --without-cxx-binding ${EXTRA_FLAGS} # LIBUSB_LIBS=${USB_LIBRARY} +- BUILD_COMMAND $(MAKE) all V=1 # $(MAKE) is ExternalProject_Add() magic to do recursive make +- INSTALL_COMMAND $(MAKE) install-strip V=1 DESTDIR="" +- STEP_TARGETS update install +- ) +- +-# + # custom target to make a hamlib source tarball + # + add_custom_target (hamlib_sources +@@ -161,7 +143,6 @@ + # build and optionally install WSJT-X using the hamlib package built + # above + # +-ExternalProject_Get_Property (hamlib INSTALL_DIR) + ExternalProject_Add (wsjtx + GIT_REPOSITORY ${wsjtx_repo} + GIT_TAG ${WSJTX_TAG} +@@ -186,14 +167,8 @@ + DEPENDEES build + ) + +-set_target_properties (hamlib PROPERTIES EXCLUDE_FROM_ALL 1) + set_target_properties (wsjtx PROPERTIES EXCLUDE_FROM_ALL 1) + +-add_dependencies (wsjtx-configure hamlib-install) +-add_dependencies (wsjtx-build hamlib-install) +-add_dependencies (wsjtx-install hamlib-install) +-add_dependencies (wsjtx-package hamlib-install) +- + # export traditional targets + add_custom_target (build ALL DEPENDS wsjtx-build) + add_custom_target (install DEPENDS wsjtx-install) diff --git a/openwebrx/docker/files/wsjtx/wsjtx.patch b/openwebrx/docker/files/wsjtx/wsjtx.patch new file mode 100644 index 0000000..61ef53e --- /dev/null +++ b/openwebrx/docker/files/wsjtx/wsjtx.patch @@ -0,0 +1,316 @@ +diff -ur wsjtx-orig/CMake/Modules/Findhamlib.cmake wsjtx/CMake/Modules/Findhamlib.cmake +--- wsjtx-orig/CMake/Modules/Findhamlib.cmake 2021-05-31 18:56:20.657682124 +0200 ++++ wsjtx/CMake/Modules/Findhamlib.cmake 2021-05-31 18:57:03.963994898 +0200 +@@ -85,4 +85,4 @@ + # Handle the QUIETLY and REQUIRED arguments and set HAMLIB_FOUND to + # TRUE if all listed variables are TRUE + include (FindPackageHandleStandardArgs) +-find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES hamlib_LIBRARY_DIRS) ++find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES) +diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt +--- wsjtx-orig/CMakeLists.txt 2021-05-31 18:56:20.657682124 +0200 ++++ wsjtx/CMakeLists.txt 2021-05-31 19:08:02.768474060 +0200 +@@ -122,7 +122,7 @@ + option (WSJT_QDEBUG_TO_FILE "Redirect Qt debuging messages to a trace file.") + option (WSJT_SOFT_KEYING "Apply a ramp to CW keying envelope to reduce transients." ON) + option (WSJT_SKIP_MANPAGES "Skip *nix manpage generation.") +-option (WSJT_GENERATE_DOCS "Generate documentation files." ON) ++option (WSJT_GENERATE_DOCS "Generate documentation files.") + option (WSJT_RIG_NONE_CAN_SPLIT "Allow split operation with \"None\" as rig.") + option (WSJT_TRACE_UDP "Debugging option that turns on UDP message protocol diagnostics.") + option (WSJT_BUILD_UTILS "Build simulators and code demonstrators." ON) +@@ -169,74 +169,7 @@ + ) + + set (wsjt_qt_CXXSRCS +- qt_helpers.cpp +- widgets/MessageBox.cpp +- MetaDataRegistry.cpp +- Network/NetworkServerLookup.cpp + revision_utils.cpp +- L10nLoader.cpp +- WFPalette.cpp +- Radio.cpp +- RadioMetaType.cpp +- NonInheritingProcess.cpp +- models/IARURegions.cpp +- models/Bands.cpp +- models/Modes.cpp +- models/FrequencyList.cpp +- models/StationList.cpp +- widgets/FrequencyLineEdit.cpp +- widgets/FrequencyDeltaLineEdit.cpp +- item_delegates/CandidateKeyFilter.cpp +- item_delegates/ForeignKeyDelegate.cpp +- validators/LiveFrequencyValidator.cpp +- GetUserId.cpp +- Audio/AudioDevice.cpp +- Transceiver/Transceiver.cpp +- Transceiver/TransceiverBase.cpp +- Transceiver/EmulateSplitTransceiver.cpp +- Transceiver/TransceiverFactory.cpp +- Transceiver/PollingTransceiver.cpp +- Transceiver/HamlibTransceiver.cpp +- Transceiver/HRDTransceiver.cpp +- Transceiver/DXLabSuiteCommanderTransceiver.cpp +- Network/NetworkMessage.cpp +- Network/MessageClient.cpp +- widgets/LettersSpinBox.cpp +- widgets/HintedSpinBox.cpp +- widgets/RestrictedSpinBox.cpp +- widgets/HelpTextWindow.cpp +- SampleDownloader.cpp +- SampleDownloader/DirectoryDelegate.cpp +- SampleDownloader/Directory.cpp +- SampleDownloader/FileNode.cpp +- SampleDownloader/RemoteFile.cpp +- DisplayManual.cpp +- MultiSettings.cpp +- validators/MaidenheadLocatorValidator.cpp +- validators/CallsignValidator.cpp +- widgets/SplashScreen.cpp +- EqualizationToolsDialog.cpp +- widgets/DoubleClickablePushButton.cpp +- widgets/DoubleClickableRadioButton.cpp +- Network/LotWUsers.cpp +- models/DecodeHighlightingModel.cpp +- widgets/DecodeHighlightingListView.cpp +- models/FoxLog.cpp +- widgets/AbstractLogWindow.cpp +- widgets/FoxLogWindow.cpp +- widgets/CabrilloLogWindow.cpp +- item_delegates/CallsignDelegate.cpp +- item_delegates/MaidenheadLocatorDelegate.cpp +- item_delegates/FrequencyDelegate.cpp +- item_delegates/FrequencyDeltaDelegate.cpp +- item_delegates/SQLiteDateTimeDelegate.cpp +- models/CabrilloLog.cpp +- logbook/AD1CCty.cpp +- logbook/WorkedBefore.cpp +- logbook/Multiplier.cpp +- Network/NetworkAccessManager.cpp +- widgets/LazyFillComboBox.cpp +- widgets/CheckableItemComboBox.cpp + ) + + set (wsjt_qtmm_CXXSRCS +@@ -857,7 +790,7 @@ + # + # libhamlib setup + # +-set (hamlib_STATIC 1) ++set (hamlib_STATIC 0) + find_package (hamlib 3 REQUIRED) + find_program (RIGCTL_EXE rigctl) + find_program (RIGCTLD_EXE rigctld) +@@ -895,9 +828,6 @@ + if (WSJT_GENERATE_DOCS) + add_subdirectory (doc) + endif (WSJT_GENERATE_DOCS) +-if (EXISTS ${CMAKE_SOURCE_DIR}/tests AND IS_DIRECTORY ${CMAKE_SOURCE_DIR}/tests) +- add_subdirectory (tests) +-endif () + + # + # Library building setup +@@ -1380,60 +1310,6 @@ + target_link_libraries (jt9 wsjt_fort wsjt_cxx fort_qt) + endif (${OPENMP_FOUND} OR APPLE) + +-# build the main application +-generate_version_info (wsjtx_VERSION_RESOURCES +- NAME wsjtx +- BUNDLE ${PROJECT_BUNDLE_NAME} +- ICON ${WSJTX_ICON_FILE} +- ) +- +-add_executable (wsjtx MACOSX_BUNDLE +- ${wsjtx_CXXSRCS} +- ${wsjtx_GENUISRCS} +- ${WSJTX_ICON_FILE} +- ${wsjtx_RESOURCES_RCC} +- ${wsjtx_VERSION_RESOURCES} +- ) +- +-if (WSJT_CREATE_WINMAIN) +- set_target_properties (wsjtx PROPERTIES WIN32_EXECUTABLE ON) +-endif (WSJT_CREATE_WINMAIN) +- +-set_target_properties (wsjtx PROPERTIES +- MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Darwin/Info.plist.in" +- MACOSX_BUNDLE_INFO_STRING "${PROJECT_DESCRIPTION}" +- MACOSX_BUNDLE_ICON_FILE "${WSJTX_ICON_FILE}" +- MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH} +- MACOSX_BUNDLE_SHORT_VERSION_STRING "v${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}" +- MACOSX_BUNDLE_LONG_VERSION_STRING "Version ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${SCS_VERSION_STR}" +- MACOSX_BUNDLE_BUNDLE_NAME "${PROJECT_BUNDLE_NAME}" +- MACOSX_BUNDLE_BUNDLE_EXECUTABLE_NAME "${PROJECT_NAME}" +- MACOSX_BUNDLE_COPYRIGHT "${PROJECT_COPYRIGHT}" +- MACOSX_BUNDLE_GUI_IDENTIFIER "org.k1jt.wsjtx" +- ) +- +-target_include_directories (wsjtx PRIVATE ${FFTW3_INCLUDE_DIRS}) +-if (APPLE) +- target_link_libraries (wsjtx wsjt_fort) +-else () +- target_link_libraries (wsjtx wsjt_fort_omp) +- if (OpenMP_C_FLAGS) +- set_target_properties (wsjtx PROPERTIES +- COMPILE_FLAGS "${OpenMP_C_FLAGS}" +- LINK_FLAGS "${OpenMP_C_FLAGS}" +- ) +- endif () +- set_target_properties (wsjtx PROPERTIES +- Fortran_MODULE_DIRECTORY ${CMAKE_BINARY_DIR}/fortran_modules_omp +- ) +- if (WIN32) +- set_target_properties (wsjtx PROPERTIES +- LINK_FLAGS -Wl,--stack,0x1000000,--heap,0x20000000 +- ) +- endif () +-endif () +-target_link_libraries (wsjtx Qt5::SerialPort wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES} ${LIBM_LIBRARIES}) +- + # make a library for WSJT-X UDP servers + # add_library (wsjtx_udp SHARED ${UDP_library_CXXSRCS}) + add_library (wsjtx_udp-static STATIC ${UDP_library_CXXSRCS}) +@@ -1473,47 +1349,9 @@ + add_executable (wsjtx_app_version AppVersion/AppVersion.cpp ${wsjtx_app_version_VERSION_RESOURCES}) + target_link_libraries (wsjtx_app_version wsjt_qt) + +-generate_version_info (message_aggregator_VERSION_RESOURCES +- NAME message_aggregator +- BUNDLE ${PROJECT_BUNDLE_NAME} +- ICON ${WSJTX_ICON_FILE} +- FILE_DESCRIPTION "Example WSJT-X UDP Message Protocol application" +- ) +-add_resources (message_aggregator_RESOURCES /qss ${message_aggregator_STYLESHEETS}) +-configure_file (UDPExamples/message_aggregator.qrc.in message_aggregator.qrc @ONLY) +-qt5_add_resources (message_aggregator_RESOURCES_RCC +- ${CMAKE_CURRENT_BINARY_DIR}/message_aggregator.qrc +- contrib/QDarkStyleSheet/qdarkstyle/style.qrc +- ) +-add_executable (message_aggregator +- ${message_aggregator_CXXSRCS} +- ${message_aggregator_RESOURCES_RCC} +- ${message_aggregator_VERSION_RESOURCES} +- ) +-target_link_libraries (message_aggregator wsjt_qt Qt5::Widgets wsjtx_udp-static) +- +-if (WSJT_CREATE_WINMAIN) +- set_target_properties (message_aggregator PROPERTIES WIN32_EXECUTABLE ON) +-endif (WSJT_CREATE_WINMAIN) +- +-if (UNIX) +- if (NOT WSJT_SKIP_MANPAGES) +- add_subdirectory (manpages) +- add_dependencies (wsjtx manpages) +- endif (NOT WSJT_SKIP_MANPAGES) +- if (NOT APPLE) +- add_subdirectory (debian) +- add_dependencies (wsjtx debian) +- endif (NOT APPLE) +-endif (UNIX) +- + # + # installation + # +-install (TARGETS wsjtx +- RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime +- BUNDLE DESTINATION . COMPONENT runtime +- ) + + # install (TARGETS wsjtx_udp EXPORT udp + # RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +@@ -1532,12 +1370,7 @@ + # DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/wsjtx + # ) + +-install (TARGETS udp_daemon message_aggregator wsjtx_app_version +- RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime +- BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime +- ) +- +-install (TARGETS jt9 wsprd fmtave fcal fmeasure ++install (TARGETS wsjtx_app_version jt9 wsprd + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime + BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime + ) +@@ -1549,38 +1382,6 @@ + ) + endif(WSJT_BUILD_UTILS) + +-install (PROGRAMS +- ${RIGCTL_EXE} +- DESTINATION ${CMAKE_INSTALL_BINDIR} +- #COMPONENT runtime +- RENAME rigctl-wsjtx${CMAKE_EXECUTABLE_SUFFIX} +- ) +- +-install (PROGRAMS +- ${RIGCTLD_EXE} +- DESTINATION ${CMAKE_INSTALL_BINDIR} +- #COMPONENT runtime +- RENAME rigctld-wsjtx${CMAKE_EXECUTABLE_SUFFIX} +- ) +- +-install (PROGRAMS +- ${RIGCTLCOM_EXE} +- DESTINATION ${CMAKE_INSTALL_BINDIR} +- #COMPONENT runtime +- RENAME rigctlcom-wsjtx${CMAKE_EXECUTABLE_SUFFIX} +- ) +- +-install (FILES +- README +- COPYING +- AUTHORS +- THANKS +- NEWS +- BUGS +- DESTINATION ${CMAKE_INSTALL_DOCDIR} +- #COMPONENT runtime +- ) +- + install (FILES + cty.dat + cty.dat_copyright.txt +@@ -1589,13 +1390,6 @@ + #COMPONENT runtime + ) + +-install (DIRECTORY +- example_log_configurations +- DESTINATION ${CMAKE_INSTALL_DOCDIR} +- FILES_MATCHING REGEX "^.*[^~]$" +- #COMPONENT runtime +- ) +- + # + # Mac installer files + # +@@ -1648,22 +1442,6 @@ + ) + + +-if (NOT WIN32 AND NOT APPLE) +- # install a desktop file so wsjtx appears in the application start +- # menu with an icon +- install ( +- FILES wsjtx.desktop message_aggregator.desktop +- DESTINATION share/applications +- #COMPONENT runtime +- ) +- install ( +- FILES icons/Unix/wsjtx_icon.png +- DESTINATION share/pixmaps +- #COMPONENT runtime +- ) +-endif (NOT WIN32 AND NOT APPLE) +- +- + # + # bundle fixup only done in non-Debug configurations + # +Only in wsjtx/: CMakeLists.txt.orig +Only in wsjtx/: .idea diff --git a/openwebrx/docker/scripts/install-connectors.sh b/openwebrx/docker/scripts/install-connectors.sh new file mode 100644 index 0000000..8386bc8 --- /dev/null +++ b/openwebrx/docker/scripts/install-connectors.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +BUILD_PACKAGES="git cmake make gcc g++" + +apt-get update +apt-get -y install --no-install-recommends $BUILD_PACKAGES + +git clone https://github.com/jketterl/owrx_connector.git +cmakebuild owrx_connector 0.5.0 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-airspy.sh b/openwebrx/docker/scripts/install-dependencies-airspy.sh new file mode 100644 index 0000000..72032ba --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-airspy.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0" +BUILD_PACKAGES="git libusb-1.0-0-dev cmake make gcc g++ pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/airspy/airspyone_host.git +# latest from master as of 2020-09-04 +cmakebuild airspyone_host 652fd7f1a8f85687641e0bd91f739694d7258ecc + +git clone https://github.com/pothosware/SoapyAirspy.git +cmakebuild SoapyAirspy 10d697b209e7f1acc8b2c8d24851d46170ef77e3 + +git clone https://github.com/airspy/airspyhf.git +# latest from master as of 2020-09-04 +cmakebuild airspyhf 8891387edddcd185e2949e9814e9ef35f46f0722 + +git clone https://github.com/pothosware/SoapyAirspyHF.git +# latest from master as of 2020-09-04 +cmakebuild SoapyAirspyHF 5488dac5b44f1432ce67b40b915f7e61d3bd4853 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-fcdpp.sh b/openwebrx/docker/scripts/install-dependencies-fcdpp.sh new file mode 100644 index 0000000..49f1439 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-fcdpp.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libhidapi-hidraw0 libhidapi-libusb0 libasound2" +BUILD_PACKAGES="git cmake make gcc g++ libhidapi-dev libasound2-dev" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/pothosware/SoapyFCDPP.git +cmakebuild SoapyFCDPP soapy-fcdpp-0.1.1 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-hackrf.sh b/openwebrx/docker/scripts/install-dependencies-hackrf.sh new file mode 100644 index 0000000..03704a3 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-hackrf.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0 libfftw3-3 udev" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-1.0-0-dev libfftw3-dev pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/mossmann/hackrf.git +cd hackrf +# latest from master as of 2020-09-04 +git checkout 6e5cbda2945c3bab0e6e1510eae418eda60c358e +cmakebuild host +cd .. +rm -rf hackrf + +git clone https://github.com/pothosware/SoapyHackRF.git +# latest from master as of 2020-09-04 +cmakebuild SoapyHackRF 7d530872f96c1cbe0ed62617c32c48ce7e103e1d + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-hpsdr.sh b/openwebrx/docker/scripts/install-dependencies-hpsdr.sh new file mode 100644 index 0000000..03ff176 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-hpsdr.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +BUILD_PACKAGES="git wget gcc libc6-dev" + +apt-get update +apt-get -y install --no-install-recommends $BUILD_PACKAGES + +pushd /tmp + +ARCH=$(uname -m) +GOVERSION=1.15.5 + +case ${ARCH} in + x86_64) + PACKAGE=go${GOVERSION}.linux-amd64.tar.gz + ;; + armv*) + PACKAGE=go${GOVERSION}.linux-armv6l.tar.gz + ;; + aarch64) + PACKAGE=go${GOVERSION}.linux-arm64.tar.gz + ;; +esac + +wget https://golang.org/dl/${PACKAGE} +tar xfz $PACKAGE + +git clone https://github.com/jancona/hpsdrconnector.git +pushd hpsdrconnector +git checkout v0.4.2 +/tmp/go/bin/go build +install -m 0755 hpsdrconnector /usr/local/bin + +popd + +rm -rf hpsdrconnector +rm -rf go +rm $PACKAGE + +popd + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-limesdr.sh b/openwebrx/docker/scripts/install-dependencies-limesdr.sh new file mode 100644 index 0000000..4f83298 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-limesdr.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +export MAKEFLAGS="-j4" + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0 libatomic1" +BUILD_PACKAGES="git libusb-1.0-0-dev cmake make gcc g++" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +SIMD_FLAGS="" +if [[ 'x86_64' == `uname -m` ]] ; then + SIMD_FLAGS="-DDEFAULT_SIMD_FLAGS=SSE3" +fi + +git clone https://github.com/myriadrf/LimeSuite.git +cd LimeSuite +# latest from master as of 2020-09-04 +git checkout 9526621f8b4c9e2a7f638b5ef50c45560dcad22a +mkdir builddir +cd builddir +cmake .. -DENABLE_EXAMPLES=OFF -DENABLE_DESKTOP=OFF -DENABLE_LIME_UTIL=OFF -DENABLE_QUICKTEST=OFF -DENABLE_OCTAVE=OFF -DENABLE_GUI=OFF -DCMAKE_CXX_STANDARD_LIBRARIES="-latomic" ${SIMD_FLAGS} +make +make install +cd ../.. +rm -rf LimeSuite + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-perseus.sh b/openwebrx/docker/scripts/install-dependencies-perseus.sh new file mode 100644 index 0000000..1d8f1c9 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-perseus.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0 libudev1" +BUILD_PACKAGES="git make gcc autoconf automake libtool libusb-1.0-0-dev xxd" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/Microtelecom/libperseus-sdr.git +cd libperseus-sdr +# latest from master as of 2020-09-04 +git checkout c2c95daeaa08bf0daed0e8ada970ab17cc264e1b +./bootstrap.sh +./configure +make +make install +ldconfig /etc/ld.so.conf.d +cd .. +rm -rf libperseus-sdr + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-plutosdr.sh b/openwebrx/docker/scripts/install-dependencies-plutosdr.sh new file mode 100644 index 0000000..aa801b5 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-plutosdr.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. ${3:-} + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0 libxml2" +BUILD_PACKAGES="git libusb-1.0-0-dev cmake make gcc g++ libxml2-dev flex bison pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/analogdevicesinc/libiio.git +cmakebuild libiio v0.21 -DCMAKE_INSTALL_PREFIX=/usr/local + +git clone https://github.com/analogdevicesinc/libad9361-iio.git +cmakebuild libad9361-iio v0.2 + +git clone https://github.com/pothosware/SoapyPlutoSDR.git +# latest from master as of 2020-09-04 +cmakebuild SoapyPlutoSDR 93717b32ef052e0dfa717aa2c1a4eb27af16111f + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-radioberry.sh b/openwebrx/docker/scripts/install-dependencies-radioberry.sh new file mode 100644 index 0000000..0172462 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-radioberry.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0 libfftw3-3 udev" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-1.0-0-dev libfftw3-dev pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/pa3gsb/Radioberry-2.x +cd Radioberry-2.x/SBC/rpi-4 + +# latest from master as of 2020-09-04 +cmakebuild SoapyRadioberrySDR 8d17de6b4dc076e628900a82f05c7cf0b16cbe24 +cd ../../.. +rm -rf Radioberry-2.x + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-rtlsdr-soapy.sh b/openwebrx/docker/scripts/install-dependencies-rtlsdr-soapy.sh new file mode 100644 index 0000000..695f31d --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-rtlsdr-soapy.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0" +BUILD_PACKAGES="git libusb-1.0-0-dev cmake make gcc g++ pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/osmocom/rtl-sdr.git +# latest from master as of 2020-09-04 +cmakebuild rtl-sdr ed0317e6a58c098874ac58b769cf2e609c18d9a5 + +git clone https://github.com/pothosware/SoapyRTLSDR.git +cmakebuild SoapyRTLSDR soapy-rtl-sdr-0.3.1 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-rtlsdr.sh b/openwebrx/docker/scripts/install-dependencies-rtlsdr.sh new file mode 100644 index 0000000..942241a --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-rtlsdr.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0.0" +BUILD_PACKAGES="git libusb-1.0.0-dev cmake make gcc g++ pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/osmocom/rtl-sdr.git +# latest from master as of 2020-09-04 +cmakebuild rtl-sdr ed0317e6a58c098874ac58b769cf2e609c18d9a5 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-runds.sh b/openwebrx/docker/scripts/install-dependencies-runds.sh new file mode 100644 index 0000000..9a4be02 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-runds.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="" +BUILD_PACKAGES="git cmake make gcc g++ pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/jketterl/runds_connector.git +cmakebuild runds_connector 0.2.0 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-sdrplay.sh b/openwebrx/docker/scripts/install-dependencies-sdrplay.sh new file mode 100644 index 0000000..d91ec6c --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-sdrplay.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0.0 udev" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-1.0-0-dev" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +ARCH=$(uname -m) + +case $ARCH in + x86_64) + BINARY=SDRplay_RSP_API-Linux-3.07.1.run + ;; + armv*) + BINARY=SDRplay_RSP_API-ARM32-3.07.2.run + ;; + aarch64) + BINARY=SDRplay_RSP_API-ARM64-3.07.1.run + ;; +esac + +wget https://www.sdrplay.com/software/$BINARY +sh $BINARY --noexec --target sdrplay +patch --verbose -Np0 < /install-lib.$ARCH.patch + +cd sdrplay +./install_lib.sh +cd .. +rm -rf sdrplay +rm $BINARY + +git clone https://github.com/pothosware/SoapySDRPlay3.git +# latest from master as of 2021-06-19 (reliability fixes) +cmakebuild SoapySDRPlay3 a869f25364a1f0d5b16169ff908aa21a2ace475d + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-soapyremote.sh b/openwebrx/docker/scripts/install-dependencies-soapyremote.sh new file mode 100644 index 0000000..a74c465 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-soapyremote.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="avahi-daemon libavahi-client3" +BUILD_PACKAGES="git cmake make gcc g++ libavahi-client-dev" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/pothosware/SoapyRemote.git +cmakebuild SoapyRemote soapy-remote-0.5.2 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-soapysdr.sh b/openwebrx/docker/scripts/install-dependencies-soapysdr.sh new file mode 100644 index 0000000..bd312b4 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-soapysdr.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libudev1" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/pothosware/SoapySDR +# latest from master as of 2020-09-04 +cmakebuild SoapySDR 580b94f3dad46899f34ec0a060dbb4534e844e57 + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-uhd.sh b/openwebrx/docker/scripts/install-dependencies-uhd.sh new file mode 100644 index 0000000..c71ff4e --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-uhd.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -euo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0.0 libboost-chrono1.67.0 libboost-date-time1.67.0 libboost-filesystem1.67.0 libboost-program-options1.67.0 libboost-regex1.67.0 libboost-test1.67.0 libboost-serialization1.67.0 libboost-thread1.67.0 libboost-system1.67.0 python3-numpy python3-mako" +BUILD_PACKAGES="git cmake make gcc g++ libusb-1.0-0-dev libboost-dev libboost-chrono-dev libboost-date-time-dev libboost-filesystem-dev libboost-program-options-dev libboost-regex-dev libboost-test-dev libboost-serialization-dev libboost-thread-dev libboost-system-dev" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/EttusResearch/uhd.git +# 3.15.0.0 Release +mkdir -p uhd/host/build +cd uhd/host/build +git checkout v3.15.0.0 +# see https://github.com/EttusResearch/uhd/issues/350 +case `uname -m` in + arm*) + cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_UTILS=OFF -DENABLE_PYTHON_API=OFF -DENABLE_EXAMPLES=OFF -DENABLE_TESTS=OFF -DENABLE_OCTOCLOCK=OFF -DENABLE_MAN_PAGES=OFF -DSTRIP_BINARIES=ON \ + -DCMAKE_CXX_FLAGS:STRING="-march=armv7-a -mfloat-abi=hard -mfpu=neon -mtune=cortex-a8 -Wno-psabi" \ + -DCMAKE_C_FLAGS:STRING="-march=armv7-a -mfloat-abi=hard -mfpu=neon -mtune=cortex-a8 -Wno-psabi" \ + -DCMAKE_ASM_FLAGS:STRING="-march=armv7-a -mfloat-abi=hard -mfpu=neon -mtune=cortex-a8 -g" .. + ;; + aarch64*) + cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_UTILS=OFF -DENABLE_PYTHON_API=OFF -DENABLE_EXAMPLES=OFF -DENABLE_TESTS=OFF -DENABLE_OCTOCLOCK=OFF -DENABLE_MAN_PAGES=OFF -DSTRIP_BINARIES=ON \ + -DCMAKE_CXX_FLAGS:STRING="-march=armv8-a -mtune=cortex-a72 -Wno-psabi" \ + -DCMAKE_C_FLAGS:STRING="-march=armv8-a -mtune=cortex-a72 -Wno-psabi" \ + -DCMAKE_ASM_FLAGS:STRING="-march=armv8-a -mtune=cortex-a72 -g" .. + ;; + x86_64) + cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_UTILS=OFF -DENABLE_PYTHON_API=OFF -DENABLE_EXAMPLES=OFF -DENABLE_TESTS=OFF -DENABLE_OCTOCLOCK=OFF -DENABLE_MAN_PAGES=OFF -DSTRIP_BINARIES=ON .. + ;; +esac +make +make install +cd ../../.. +rm -rf uhd + +git clone https://github.com/pothosware/SoapyUHD.git +cmakebuild SoapyUHD soapy-uhd-0.4.1 + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies.sh b/openwebrx/docker/scripts/install-dependencies.sh new file mode 100644 index 0000000..8820f93 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies.sh @@ -0,0 +1,116 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake ${CMAKE_ARGS:-} .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="sox libfftw3-bin python3 python3-setuptools netcat-openbsd libsndfile1 liblapack3 libusb-1.0-0 libqt5core5a libreadline7 libgfortran4 libgomp1 libasound2 libudev1 ca-certificates libqt5gui5 libqt5sql5 libqt5printsupport5 libpulse0 libfaad2 libopus0 libboost-program-options1.67.0 libboost-log1.67.0" +BUILD_PACKAGES="wget git libsndfile1-dev libfftw3-dev cmake make gcc g++ liblapack-dev texinfo gfortran libusb-1.0-0-dev qtbase5-dev qtmultimedia5-dev qttools5-dev libqt5serialport5-dev qttools5-dev-tools asciidoctor asciidoc libasound2-dev libudev-dev libhamlib-dev patch xsltproc qt5-default libfaad-dev libopus-dev libboost-dev libboost-program-options-dev libboost-log-dev libboost-regex-dev" +apt-get update +apt-get -y install auto-apt-proxy +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +case `uname -m` in + arm*) + PLATFORM=armhf + ;; + aarch64*) + PLATFORM=aarch64 + ;; + x86_64*) + PLATFORM=amd64 + ;; +esac + +wget https://github.com/just-containers/s6-overlay/releases/download/v1.21.8.0/s6-overlay-${PLATFORM}.tar.gz +tar xzf s6-overlay-${PLATFORM}.tar.gz -C / +rm s6-overlay-${PLATFORM}.tar.gz + +JS8CALL_VERSION=2.2.0 +JS8CALL_DIR=js8call +JS8CALL_TGZ=js8call-${JS8CALL_VERSION}.tgz +wget http://files.js8call.com/${JS8CALL_VERSION}/${JS8CALL_TGZ} +tar xfz ${JS8CALL_TGZ} +# patch allows us to build against the packaged hamlib +patch -Np1 -d ${JS8CALL_DIR} < /js8call-hamlib.patch +rm /js8call-hamlib.patch +CMAKE_ARGS="-D CMAKE_CXX_FLAGS=-DJS8_USE_HAMLIB_THREE" cmakebuild ${JS8CALL_DIR} +rm ${JS8CALL_TGZ} + +WSJT_DIR=wsjtx-2.4.0 +WSJT_TGZ=${WSJT_DIR}.tgz +wget http://physics.princeton.edu/pulsar/k1jt/${WSJT_TGZ} +tar xfz ${WSJT_TGZ} +patch -Np0 -d ${WSJT_DIR} < /wsjtx-hamlib.patch +mv /wsjtx.patch ${WSJT_DIR} +cmakebuild ${WSJT_DIR} +rm ${WSJT_TGZ} + +git clone --depth 1 -b 1.6 https://github.com/wb2osz/direwolf.git +cd direwolf +# hamlib is present (necessary for the wsjt-x and js8call builds) and would be used, but there's no real need. +# this patch prevents direwolf from linking to it, and it can be stripped at the end of the script. +patch -Np1 < /direwolf-hamlib.patch +mkdir build +cd build +cmake .. +make +make install +cd ../.. +rm -rf direwolf +# strip lots of generic documentation that will never be read inside a docker container +rm /usr/local/share/doc/direwolf/*.pdf +# examples are pointless, too +rm -rf /usr/local/share/doc/direwolf/examples/ + +git clone https://github.com/drowe67/codec2.git +cd codec2 +# latest commit from master as of 2020-10-04 +git checkout 55d7bb8d1bddf881bdbfcb971a718b83e6344598 +mkdir build +cd build +cmake .. +make +make install +install -m 0755 src/freedv_rx /usr/local/bin +cd ../.. +rm -rf codec2 + +wget https://downloads.sourceforge.net/project/drm/dream/2.1.1/dream-2.1.1-svn808.tar.gz +tar xvfz dream-2.1.1-svn808.tar.gz +pushd dream +patch -Np0 < /dream.patch +qmake CONFIG+=console +make +make install +popd +rm -rf dream +rm dream-2.1.1-svn808.tar.gz + +git clone https://github.com/mobilinkd/m17-cxx-demod.git +cmakebuild m17-cxx-demod v2.0 + +git clone https://github.com/hessu/aprs-symbols /usr/share/aprs-symbols +pushd /usr/share/aprs-symbols +git checkout 5c2abe2658ee4d2563f3c73b90c6f59124839802 +# remove unused files (including git meta information) +rm -rf .git aprs-symbols.ai aprs-sym-export.js +popd + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-owrx-tools.sh b/openwebrx/docker/scripts/install-owrx-tools.sh new file mode 100644 index 0000000..1cdfb32 --- /dev/null +++ b/openwebrx/docker/scripts/install-owrx-tools.sh @@ -0,0 +1,53 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake ${CMAKE_ARGS:-} .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libfftw3-bin libprotobuf17" +BUILD_PACKAGES="git autoconf automake libtool libfftw3-dev pkg-config cmake make gcc g++ libprotobuf-dev protobuf-compiler" +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/jketterl/js8py.git +pushd js8py +git checkout 0.1.0 +python3 setup.py install +popd +rm -rf js8py + +git clone https://github.com/jketterl/csdr.git +cd csdr +git checkout 0.17.0 +autoreconf -i +./configure +make +make install +cd .. +rm -rf csdr + +git clone https://github.com/jketterl/codecserver.git +mkdir -p /usr/local/etc/codecserver +cp codecserver/conf/codecserver.conf /usr/local/etc/codecserver +cmakebuild codecserver 0.1.0 + +git clone https://github.com/jketterl/digiham.git +cmakebuild digiham 0.5.0 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/run.sh b/openwebrx/docker/scripts/run.sh new file mode 100644 index 0000000..cde6fdf --- /dev/null +++ b/openwebrx/docker/scripts/run.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail + +mkdir -p /etc/openwebrx/openwebrx.conf.d +mkdir -p /var/lib/openwebrx +mkdir -p /tmp/openwebrx/ +if [[ ! -f /etc/openwebrx/openwebrx.conf.d/20-temporary-directory.conf ]] ; then + cat << EOF > /etc/openwebrx/openwebrx.conf.d/20-temporary-directory.conf +[core] +temporary_directory = /tmp/openwebrx +EOF +fi +if [[ ! -f /etc/openwebrx/bands.json ]] ; then + cp bands.json /etc/openwebrx/ +fi +if [[ ! -f /etc/openwebrx/openwebrx.conf ]] ; then + cp openwebrx.conf /etc/openwebrx/ +fi +if [[ ! -z "${OPENWEBRX_ADMIN_USER:-}" ]] && [[ ! -z "${OPENWEBRX_ADMIN_PASSWORD:-}" ]] ; then + if ! python3 openwebrx.py admin --silent hasuser "${OPENWEBRX_ADMIN_USER}" ; then + OWRX_PASSWORD="${OPENWEBRX_ADMIN_PASSWORD}" python3 openwebrx.py admin --noninteractive adduser "${OPENWEBRX_ADMIN_USER}" + fi +fi + + +_term() { + echo "Caught signal!" + kill -TERM "$child" 2>/dev/null +} + +trap _term SIGTERM SIGINT + +python3 openwebrx.py $@ & + +child=$! +wait "$child" + diff --git a/openwebrx/htdocs/__init__.py b/openwebrx/htdocs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/htdocs/__pycache__/__init__.cpython-37.pyc b/openwebrx/htdocs/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..e5957a1 Binary files /dev/null and b/openwebrx/htdocs/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/htdocs/apple-touch-icon.png b/openwebrx/htdocs/apple-touch-icon.png new file mode 100644 index 0000000..5bc3c15 Binary files /dev/null and b/openwebrx/htdocs/apple-touch-icon.png differ diff --git a/openwebrx/htdocs/css/admin.css b/openwebrx/htdocs/css/admin.css new file mode 100644 index 0000000..b956e9e --- /dev/null +++ b/openwebrx/htdocs/css/admin.css @@ -0,0 +1,162 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +html, body { + height: unset; +} + +body { + margin-bottom: 5rem; +} + +hr { + background: #444; +} + +.buttons { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: #222; + z-index: 2; + padding: 10px; + text-align: right; + border-top: 1px solid #444; +} + +.row .map-input { + margin: 15px 15px 0; +} + +.settings-section h3 { + margin-top: 1em; + margin-bottom: 1em; +} + +h1 { + margin: 1em 0; + text-align: center; +} + +.matrix { + display: grid; +} + +.q65-matrix { + grid-template-columns: repeat(5, auto); +} + +.imageupload .image-container { + max-width: 100%; + padding: 7px; +} + +.imageupload img.webrx-top-photo { + max-height: 350px; + max-width: 100%; +} + +.settings-grid > div { + padding: 20px; +} + +.settings-grid .btn { + width: 100%; + height: 100px; + padding: 20px; + font-size: 1.2rem; +} + +.tab-body { + overflow: auto; + border: 1px solid #444; + border-top: none; + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +.tab-body .form-group { + padding-right: 15px; +} + +.bookmarks table .frequency, .bookmark-list table .frequency { + text-align: right; +} + +.bookmarks table input, .bookmarks table select { + width: initial; + text-align: inherit; + display: initial; +} + +.bookmark-list table .form-check-input { + margin-left: 0; +} + +.actions { + margin: 1rem 0; +} + +.actions .btn { + width: 100%; +} + +.wsjt-decoding-depths-table { + width: auto; + margin: 0; +} + +.wsjt-decoding-depths-table td:first-child { + padding-left: 0; +} + +.sdr-device-list .list-group-item, +.sdr-profile-list .list-group-item { + background: initial; +} + +.sdr-device-list .sdr-profile-list { + max-height: 20rem; + overflow-y: auto; +} + +.removable-group.removable, .add-group { + display: flex; + flex-direction: row; +} + +.removable-group.removable .removable-item, .add-group .add-group-select { + flex: 1 0 auto; + margin-right: .25rem; +} + +.removable-group.removable .option-remove-button, .add-group .option-add-button { + flex: 0 0 70px; +} + +.option-add-button, .option-remove-button { + width: 70px; +} + +.scheduler-static-time-inputs { + display: flex; + flex-direction: row; +} + +.scheduler-static-time-inputs > * { + flex: 0 0 auto; + width: unset; +} + +.scheduler-static-time-inputs > select { + flex: 1 0 auto; +} + +.breadcrumb { + margin-top: .5rem; +} + +.imageupload.is-invalid ~ .invalid-feedback { + display: block; +} \ No newline at end of file diff --git a/openwebrx/htdocs/css/bootstrap.min.css b/openwebrx/htdocs/css/bootstrap.min.css new file mode 100644 index 0000000..43d80a0 --- /dev/null +++ b/openwebrx/htdocs/css/bootstrap.min.css @@ -0,0 +1,12 @@ +/*! + * Bootswatch v4.5.0 + * Homepage: https://bootswatch.com + * Copyright 2012-2020 Thomas Park + * Licensed under MIT + * Based on Bootstrap +*//*! + * Bootstrap v4.5.0 (https://getbootstrap.com/) + * Copyright 2011-2020 The Bootstrap Authors + * Copyright 2011-2020 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */@import url("https://fonts.googleapis.com/css?family=Lato:400,700,400italic&display=swap");:root{--blue: #375a7f;--indigo: #6610f2;--purple: #6f42c1;--pink: #e83e8c;--red: #E74C3C;--orange: #fd7e14;--yellow: #F39C12;--green: #00bc8c;--teal: #20c997;--cyan: #3498DB;--white: #fff;--gray: #888;--gray-dark: #303030;--primary: #375a7f;--secondary: #444;--success: #00bc8c;--info: #3498DB;--warning: #F39C12;--danger: #E74C3C;--light: #adb5bd;--dark: #303030;--breakpoint-xs: 0;--breakpoint-sm: 576px;--breakpoint-md: 768px;--breakpoint-lg: 992px;--breakpoint-xl: 1200px;--font-family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";--font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}*,*::before,*::after{-webkit-box-sizing:border-box;box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0)}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:"Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";font-size:0.9375rem;font-weight:400;line-height:1.5;color:#fff;text-align:left;background-color:#222}[tabindex="-1"]:focus:not(:focus-visible){outline:0 !important}hr{-webkit-box-sizing:content-box;box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:0.5rem}p{margin-top:0;margin-bottom:1rem}abbr[title],abbr[data-original-title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#00bc8c;text-decoration:none;background-color:transparent}a:hover{color:#007053;text-decoration:underline}a:not([href]){color:inherit;text-decoration:none}a:not([href]):hover{color:inherit;text-decoration:none}pre,code,kbd,samp{font-family:SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:0.75rem;padding-bottom:0.75rem;color:#888;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:0.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}input,button,select,optgroup,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role="button"]{cursor:pointer}select{word-wrap:normal}button,[type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button:not(:disabled),[type="button"]:not(:disabled),[type="reset"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{padding:0;border-style:none}input[type="radio"],input[type="checkbox"]{-webkit-box-sizing:border-box;box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{outline-offset:-2px;-webkit-appearance:none}[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none !important}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{margin-bottom:0.5rem;font-weight:500;line-height:1.2}h1,.h1{font-size:3rem}h2,.h2{font-size:2.5rem}h3,.h3{font-size:2rem}h4,.h4{font-size:1.40625rem}h5,.h5{font-size:1.171875rem}h6,.h6{font-size:0.9375rem}.lead{font-size:1.171875rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,0.1)}small,.small{font-size:80%;font-weight:400}mark,.mark{padding:0.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:0.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.171875rem}.blockquote-footer{display:block;font-size:80%;color:#888}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:0.25rem;background-color:#222;border:1px solid #dee2e6;border-radius:0.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:0.5rem;line-height:1}.figure-caption{font-size:90%;color:#888}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}a>code{color:inherit}kbd{padding:0.2rem 0.4rem;font-size:87.5%;color:#fff;background-color:#222;border-radius:0.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:inherit}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width: 576px){.container{max-width:540px}}@media (min-width: 768px){.container{max-width:720px}}@media (min-width: 992px){.container{max-width:960px}}@media (min-width: 1200px){.container{max-width:1140px}}.container-fluid,.container-sm,.container-md,.container-lg,.container-xl{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width: 576px){.container,.container-sm{max-width:540px}}@media (min-width: 768px){.container,.container-sm,.container-md{max-width:720px}}@media (min-width: 992px){.container,.container-sm,.container-md,.container-lg{max-width:960px}}@media (min-width: 1200px){.container,.container-sm,.container-md,.container-lg,.container-xl{max-width:1140px}}.row{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*="col-"]{padding-right:0;padding-left:0}.col-1,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-10,.col-11,.col-12,.col,.col-auto,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm,.col-sm-auto,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-md,.col-md-auto,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg,.col-lg-auto,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.row-cols-1>*{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-2>*{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-3>*{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-4>*{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-5>*{-webkit-box-flex:0;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-6>*{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-webkit-box-flex:0;-ms-flex:0 0 8.3333333333%;flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-2{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-5{-webkit-box-flex:0;-ms-flex:0 0 41.6666666667%;flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-webkit-box-flex:0;-ms-flex:0 0 58.3333333333%;flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-8{-webkit-box-flex:0;-ms-flex:0 0 66.6666666667%;flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-webkit-box-flex:0;-ms-flex:0 0 83.3333333333%;flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-11{-webkit-box-flex:0;-ms-flex:0 0 91.6666666667%;flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-1{margin-left:8.3333333333%}.offset-2{margin-left:16.6666666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.3333333333%}.offset-5{margin-left:41.6666666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.3333333333%}.offset-8{margin-left:66.6666666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.3333333333%}.offset-11{margin-left:91.6666666667%}@media (min-width: 576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.row-cols-sm-1>*{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-sm-4>*{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{-webkit-box-flex:0;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-sm-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-webkit-box-flex:0;-ms-flex:0 0 8.3333333333%;flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-sm-2{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-sm-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-sm-5{-webkit-box-flex:0;-ms-flex:0 0 41.6666666667%;flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-sm-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-webkit-box-flex:0;-ms-flex:0 0 58.3333333333%;flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-sm-8{-webkit-box-flex:0;-ms-flex:0 0 66.6666666667%;flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-sm-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-webkit-box-flex:0;-ms-flex:0 0 83.3333333333%;flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-sm-11{-webkit-box-flex:0;-ms-flex:0 0 91.6666666667%;flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-sm-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-sm-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-sm-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-sm-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-sm-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-sm-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-sm-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-sm-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-sm-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-sm-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-sm-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-sm-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-sm-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-sm-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-sm-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.3333333333%}.offset-sm-2{margin-left:16.6666666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.3333333333%}.offset-sm-5{margin-left:41.6666666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.3333333333%}.offset-sm-8{margin-left:66.6666666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.3333333333%}.offset-sm-11{margin-left:91.6666666667%}}@media (min-width: 768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.row-cols-md-1>*{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-md-2>*{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-md-3>*{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-md-4>*{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-md-5>*{-webkit-box-flex:0;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-md-6>*{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-md-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-webkit-box-flex:0;-ms-flex:0 0 8.3333333333%;flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-md-2{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-md-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-md-5{-webkit-box-flex:0;-ms-flex:0 0 41.6666666667%;flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-md-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-webkit-box-flex:0;-ms-flex:0 0 58.3333333333%;flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-md-8{-webkit-box-flex:0;-ms-flex:0 0 66.6666666667%;flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-md-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-webkit-box-flex:0;-ms-flex:0 0 83.3333333333%;flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-md-11{-webkit-box-flex:0;-ms-flex:0 0 91.6666666667%;flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-md-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-md-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-md-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-md-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-md-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-md-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-md-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-md-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-md-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-md-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-md-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-md-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-md-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-md-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-md-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.3333333333%}.offset-md-2{margin-left:16.6666666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.3333333333%}.offset-md-5{margin-left:41.6666666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.3333333333%}.offset-md-8{margin-left:66.6666666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.3333333333%}.offset-md-11{margin-left:91.6666666667%}}@media (min-width: 992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.row-cols-lg-1>*{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-lg-4>*{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{-webkit-box-flex:0;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-lg-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-webkit-box-flex:0;-ms-flex:0 0 8.3333333333%;flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-lg-2{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-lg-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-lg-5{-webkit-box-flex:0;-ms-flex:0 0 41.6666666667%;flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-lg-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-webkit-box-flex:0;-ms-flex:0 0 58.3333333333%;flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-lg-8{-webkit-box-flex:0;-ms-flex:0 0 66.6666666667%;flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-lg-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-webkit-box-flex:0;-ms-flex:0 0 83.3333333333%;flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-lg-11{-webkit-box-flex:0;-ms-flex:0 0 91.6666666667%;flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-lg-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-lg-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-lg-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-lg-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-lg-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-lg-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-lg-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-lg-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-lg-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-lg-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-lg-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-lg-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-lg-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-lg-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-lg-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.3333333333%}.offset-lg-2{margin-left:16.6666666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.3333333333%}.offset-lg-5{margin-left:41.6666666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.3333333333%}.offset-lg-8{margin-left:66.6666666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.3333333333%}.offset-lg-11{margin-left:91.6666666667%}}@media (min-width: 1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.row-cols-xl-1>*{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-xl-4>*{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{-webkit-box-flex:0;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-xl-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-webkit-box-flex:0;-ms-flex:0 0 8.3333333333%;flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-xl-2{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-xl-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-xl-5{-webkit-box-flex:0;-ms-flex:0 0 41.6666666667%;flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-xl-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-webkit-box-flex:0;-ms-flex:0 0 58.3333333333%;flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-xl-8{-webkit-box-flex:0;-ms-flex:0 0 66.6666666667%;flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-xl-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-webkit-box-flex:0;-ms-flex:0 0 83.3333333333%;flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-xl-11{-webkit-box-flex:0;-ms-flex:0 0 91.6666666667%;flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-xl-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-xl-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-xl-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-xl-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-xl-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-xl-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-xl-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-xl-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-xl-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-xl-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-xl-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-xl-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-xl-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-xl-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-xl-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.3333333333%}.offset-xl-2{margin-left:16.6666666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.3333333333%}.offset-xl-5{margin-left:41.6666666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.3333333333%}.offset-xl-8{margin-left:66.6666666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.3333333333%}.offset-xl-11{margin-left:91.6666666667%}}.table{width:100%;margin-bottom:1rem;color:#fff}.table th,.table td{padding:0.75rem;vertical-align:top;border-top:1px solid #444}.table thead th{vertical-align:bottom;border-bottom:2px solid #444}.table tbody+tbody{border-top:2px solid #444}.table-sm th,.table-sm td{padding:0.3rem}.table-bordered{border:1px solid #444}.table-bordered th,.table-bordered td{border:1px solid #444}.table-bordered thead th,.table-bordered thead td{border-bottom-width:2px}.table-borderless th,.table-borderless td,.table-borderless thead th,.table-borderless tbody+tbody{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:#303030}.table-hover tbody tr:hover{color:#fff;background-color:rgba(0,0,0,0.075)}.table-primary,.table-primary>th,.table-primary>td{background-color:#c7d1db}.table-primary th,.table-primary td,.table-primary thead th,.table-primary tbody+tbody{border-color:#97a9bc}.table-hover .table-primary:hover{background-color:#b7c4d1}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#b7c4d1}.table-secondary,.table-secondary>th,.table-secondary>td{background-color:#cbcbcb}.table-secondary th,.table-secondary td,.table-secondary thead th,.table-secondary tbody+tbody{border-color:#9e9e9e}.table-hover .table-secondary:hover{background-color:#bebebe}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#bebebe}.table-success,.table-success>th,.table-success>td{background-color:#b8ecdf}.table-success th,.table-success td,.table-success thead th,.table-success tbody+tbody{border-color:#7adcc3}.table-hover .table-success:hover{background-color:#a4e7d6}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#a4e7d6}.table-info,.table-info>th,.table-info>td{background-color:#c6e2f5}.table-info th,.table-info td,.table-info thead th,.table-info tbody+tbody{border-color:#95c9ec}.table-hover .table-info:hover{background-color:#b0d7f1}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#b0d7f1}.table-warning,.table-warning>th,.table-warning>td{background-color:#fce3bd}.table-warning th,.table-warning td,.table-warning thead th,.table-warning tbody+tbody{border-color:#f9cc84}.table-hover .table-warning:hover{background-color:#fbd9a5}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#fbd9a5}.table-danger,.table-danger>th,.table-danger>td{background-color:#f8cdc8}.table-danger th,.table-danger td,.table-danger thead th,.table-danger tbody+tbody{border-color:#f3a29a}.table-hover .table-danger:hover{background-color:#f5b8b1}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f5b8b1}.table-light,.table-light>th,.table-light>td{background-color:#e8eaed}.table-light th,.table-light td,.table-light thead th,.table-light tbody+tbody{border-color:#d4d9dd}.table-hover .table-light:hover{background-color:#dadde2}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#dadde2}.table-dark,.table-dark>th,.table-dark>td{background-color:#c5c5c5}.table-dark th,.table-dark td,.table-dark thead th,.table-dark tbody+tbody{border-color:#939393}.table-hover .table-dark:hover{background-color:#b8b8b8}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b8b8b8}.table-active,.table-active>th,.table-active>td{background-color:rgba(0,0,0,0.075)}.table-hover .table-active:hover{background-color:rgba(0,0,0,0.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,0.075)}.table .thead-dark th{color:#fff;background-color:#303030;border-color:#434343}.table .thead-light th{color:#444;background-color:#ebebeb;border-color:#444}.table-dark{color:#fff;background-color:#303030}.table-dark th,.table-dark td,.table-dark thead th{border-color:#434343}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,0.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,0.075)}@media (max-width: 575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width: 767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width: 991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width: 1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + 0.75rem + 2px);padding:0.375rem 0.75rem;font-size:0.9375rem;font-weight:400;line-height:1.5;color:#444;background-color:#fff;background-clip:padding-box;border:1px solid #222;border-radius:0.25rem;-webkit-transition:border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control{-webkit-transition:none;transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #444}.form-control:focus{color:#444;background-color:#fff;border-color:#739ac2;outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.form-control::-webkit-input-placeholder{color:#888;opacity:1}.form-control::-ms-input-placeholder{color:#888;opacity:1}.form-control::placeholder{color:#888;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#ebebeb;opacity:1}input[type="date"].form-control,input[type="time"].form-control,input[type="datetime-local"].form-control,input[type="month"].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:focus::-ms-value{color:#444;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(0.375rem + 1px);padding-bottom:calc(0.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(0.5rem + 1px);padding-bottom:calc(0.5rem + 1px);font-size:1.171875rem;line-height:1.5}.col-form-label-sm{padding-top:calc(0.25rem + 1px);padding-bottom:calc(0.25rem + 1px);font-size:0.8203125rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding:0.375rem 0;margin-bottom:0;font-size:0.9375rem;line-height:1.5;color:#fff;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-sm,.form-control-plaintext.form-control-lg{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + 0.5rem + 2px);padding:0.25rem 0.5rem;font-size:0.8203125rem;line-height:1.5;border-radius:0.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:0.5rem 1rem;font-size:1.171875rem;line-height:1.5;border-radius:0.3rem}select.form-control[size],select.form-control[multiple]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:0.25rem}.form-row{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*="col-"]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:0.3rem;margin-left:-1.25rem}.form-check-input[disabled] ~ .form-check-label,.form-check-input:disabled ~ .form-check-label{color:#888}.form-check-label{margin-bottom:0}.form-check-inline{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:0.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:0.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:0.25rem;font-size:80%;color:#00bc8c}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:0.25rem 0.5rem;margin-top:.1rem;font-size:0.8203125rem;line-height:1.5;color:#fff;background-color:rgba(0,188,140,0.9);border-radius:0.25rem}.was-validated :valid ~ .valid-feedback,.was-validated :valid ~ .valid-tooltip,.is-valid ~ .valid-feedback,.is-valid ~ .valid-tooltip{display:block}.was-validated .form-control:valid,.form-control.is-valid{border-color:#00bc8c;padding-right:calc(1.5em + 0.75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2300bc8c' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(0.375em + 0.1875rem) center;background-size:calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-control:valid:focus,.form-control.is-valid:focus{border-color:#00bc8c;-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + 0.75rem);background-position:top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem)}.was-validated .custom-select:valid,.custom-select.is-valid{border-color:#00bc8c;padding-right:calc(0.75em + 2.3125rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23303030' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2300bc8c' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .custom-select:valid:focus,.custom-select.is-valid:focus{border-color:#00bc8c;-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25)}.was-validated .form-check-input:valid ~ .form-check-label,.form-check-input.is-valid ~ .form-check-label{color:#00bc8c}.was-validated .form-check-input:valid ~ .valid-feedback,.was-validated .form-check-input:valid ~ .valid-tooltip,.form-check-input.is-valid ~ .valid-feedback,.form-check-input.is-valid ~ .valid-tooltip{display:block}.was-validated .custom-control-input:valid ~ .custom-control-label,.custom-control-input.is-valid ~ .custom-control-label{color:#00bc8c}.was-validated .custom-control-input:valid ~ .custom-control-label::before,.custom-control-input.is-valid ~ .custom-control-label::before{border-color:#00bc8c}.was-validated .custom-control-input:valid:checked ~ .custom-control-label::before,.custom-control-input.is-valid:checked ~ .custom-control-label::before{border-color:#00efb2;background-color:#00efb2}.was-validated .custom-control-input:valid:focus ~ .custom-control-label::before,.custom-control-input.is-valid:focus ~ .custom-control-label::before{-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25)}.was-validated .custom-control-input:valid:focus:not(:checked) ~ .custom-control-label::before,.custom-control-input.is-valid:focus:not(:checked) ~ .custom-control-label::before{border-color:#00bc8c}.was-validated .custom-file-input:valid ~ .custom-file-label,.custom-file-input.is-valid ~ .custom-file-label{border-color:#00bc8c}.was-validated .custom-file-input:valid:focus ~ .custom-file-label,.custom-file-input.is-valid:focus ~ .custom-file-label{border-color:#00bc8c;-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25)}.invalid-feedback{display:none;width:100%;margin-top:0.25rem;font-size:80%;color:#E74C3C}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:0.25rem 0.5rem;margin-top:.1rem;font-size:0.8203125rem;line-height:1.5;color:#fff;background-color:rgba(231,76,60,0.9);border-radius:0.25rem}.was-validated :invalid ~ .invalid-feedback,.was-validated :invalid ~ .invalid-tooltip,.is-invalid ~ .invalid-feedback,.is-invalid ~ .invalid-tooltip{display:block}.was-validated .form-control:invalid,.form-control.is-invalid{border-color:#E74C3C;padding-right:calc(1.5em + 0.75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23E74C3C' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23E74C3C' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(0.375em + 0.1875rem) center;background-size:calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-control:invalid:focus,.form-control.is-invalid:focus{border-color:#E74C3C;-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + 0.75rem);background-position:top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem)}.was-validated .custom-select:invalid,.custom-select.is-invalid{border-color:#E74C3C;padding-right:calc(0.75em + 2.3125rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23303030' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23E74C3C' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23E74C3C' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .custom-select:invalid:focus,.custom-select.is-invalid:focus{border-color:#E74C3C;-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25)}.was-validated .form-check-input:invalid ~ .form-check-label,.form-check-input.is-invalid ~ .form-check-label{color:#E74C3C}.was-validated .form-check-input:invalid ~ .invalid-feedback,.was-validated .form-check-input:invalid ~ .invalid-tooltip,.form-check-input.is-invalid ~ .invalid-feedback,.form-check-input.is-invalid ~ .invalid-tooltip{display:block}.was-validated .custom-control-input:invalid ~ .custom-control-label,.custom-control-input.is-invalid ~ .custom-control-label{color:#E74C3C}.was-validated .custom-control-input:invalid ~ .custom-control-label::before,.custom-control-input.is-invalid ~ .custom-control-label::before{border-color:#E74C3C}.was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before,.custom-control-input.is-invalid:checked ~ .custom-control-label::before{border-color:#ed7669;background-color:#ed7669}.was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before,.custom-control-input.is-invalid:focus ~ .custom-control-label::before{-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25)}.was-validated .custom-control-input:invalid:focus:not(:checked) ~ .custom-control-label::before,.custom-control-input.is-invalid:focus:not(:checked) ~ .custom-control-label::before{border-color:#E74C3C}.was-validated .custom-file-input:invalid ~ .custom-file-label,.custom-file-input.is-invalid ~ .custom-file-label{border-color:#E74C3C}.was-validated .custom-file-input:invalid:focus ~ .custom-file-label,.custom-file-input.is-invalid:focus ~ .custom-file-label{border-color:#E74C3C;-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25)}.form-inline{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width: 576px){.form-inline label{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .input-group,.form-inline .custom-select{width:auto}.form-inline .form-check{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:0.25rem;margin-left:0}.form-inline .custom-control{-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#fff;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:0.375rem 0.75rem;font-size:0.9375rem;line-height:1.5;border-radius:0.25rem;-webkit-transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.btn{-webkit-transition:none;transition:none}}.btn:hover{color:#fff;text-decoration:none}.btn:focus,.btn.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.btn.disabled,.btn:disabled{opacity:0.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#375a7f;border-color:#375a7f}.btn-primary:hover{color:#fff;background-color:#2b4764;border-color:#28415b}.btn-primary:focus,.btn-primary.focus{color:#fff;background-color:#2b4764;border-color:#28415b;-webkit-box-shadow:0 0 0 0.2rem rgba(85,115,146,0.5);box-shadow:0 0 0 0.2rem rgba(85,115,146,0.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#375a7f;border-color:#375a7f}.btn-primary:not(:disabled):not(.disabled):active,.btn-primary:not(:disabled):not(.disabled).active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#28415b;border-color:#243a53}.btn-primary:not(:disabled):not(.disabled):active:focus,.btn-primary:not(:disabled):not(.disabled).active:focus,.show>.btn-primary.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(85,115,146,0.5);box-shadow:0 0 0 0.2rem rgba(85,115,146,0.5)}.btn-secondary{color:#fff;background-color:#444;border-color:#444}.btn-secondary:hover{color:#fff;background-color:#313131;border-color:#2b2a2a}.btn-secondary:focus,.btn-secondary.focus{color:#fff;background-color:#313131;border-color:#2b2a2a;-webkit-box-shadow:0 0 0 0.2rem rgba(96,96,96,0.5);box-shadow:0 0 0 0.2rem rgba(96,96,96,0.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#444;border-color:#444}.btn-secondary:not(:disabled):not(.disabled):active,.btn-secondary:not(:disabled):not(.disabled).active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#2b2a2a;border-color:#242424}.btn-secondary:not(:disabled):not(.disabled):active:focus,.btn-secondary:not(:disabled):not(.disabled).active:focus,.show>.btn-secondary.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(96,96,96,0.5);box-shadow:0 0 0 0.2rem rgba(96,96,96,0.5)}.btn-success{color:#fff;background-color:#00bc8c;border-color:#00bc8c}.btn-success:hover{color:#fff;background-color:#009670;border-color:#008966}.btn-success:focus,.btn-success.focus{color:#fff;background-color:#009670;border-color:#008966;-webkit-box-shadow:0 0 0 0.2rem rgba(38,198,157,0.5);box-shadow:0 0 0 0.2rem rgba(38,198,157,0.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#00bc8c;border-color:#00bc8c}.btn-success:not(:disabled):not(.disabled):active,.btn-success:not(:disabled):not(.disabled).active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#008966;border-color:#007c5d}.btn-success:not(:disabled):not(.disabled):active:focus,.btn-success:not(:disabled):not(.disabled).active:focus,.show>.btn-success.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(38,198,157,0.5);box-shadow:0 0 0 0.2rem rgba(38,198,157,0.5)}.btn-info{color:#fff;background-color:#3498DB;border-color:#3498DB}.btn-info:hover{color:#fff;background-color:#2384c6;border-color:#217dbb}.btn-info:focus,.btn-info.focus{color:#fff;background-color:#2384c6;border-color:#217dbb;-webkit-box-shadow:0 0 0 0.2rem rgba(82,167,224,0.5);box-shadow:0 0 0 0.2rem rgba(82,167,224,0.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#3498DB;border-color:#3498DB}.btn-info:not(:disabled):not(.disabled):active,.btn-info:not(:disabled):not(.disabled).active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#217dbb;border-color:#1f76b0}.btn-info:not(:disabled):not(.disabled):active:focus,.btn-info:not(:disabled):not(.disabled).active:focus,.show>.btn-info.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(82,167,224,0.5);box-shadow:0 0 0 0.2rem rgba(82,167,224,0.5)}.btn-warning{color:#fff;background-color:#F39C12;border-color:#F39C12}.btn-warning:hover{color:#fff;background-color:#d4860b;border-color:#c87f0a}.btn-warning:focus,.btn-warning.focus{color:#fff;background-color:#d4860b;border-color:#c87f0a;-webkit-box-shadow:0 0 0 0.2rem rgba(245,171,54,0.5);box-shadow:0 0 0 0.2rem rgba(245,171,54,0.5)}.btn-warning.disabled,.btn-warning:disabled{color:#fff;background-color:#F39C12;border-color:#F39C12}.btn-warning:not(:disabled):not(.disabled):active,.btn-warning:not(:disabled):not(.disabled).active,.show>.btn-warning.dropdown-toggle{color:#fff;background-color:#c87f0a;border-color:#bc770a}.btn-warning:not(:disabled):not(.disabled):active:focus,.btn-warning:not(:disabled):not(.disabled).active:focus,.show>.btn-warning.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(245,171,54,0.5);box-shadow:0 0 0 0.2rem rgba(245,171,54,0.5)}.btn-danger{color:#fff;background-color:#E74C3C;border-color:#E74C3C}.btn-danger:hover{color:#fff;background-color:#e12e1c;border-color:#d62c1a}.btn-danger:focus,.btn-danger.focus{color:#fff;background-color:#e12e1c;border-color:#d62c1a;-webkit-box-shadow:0 0 0 0.2rem rgba(235,103,89,0.5);box-shadow:0 0 0 0.2rem rgba(235,103,89,0.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#E74C3C;border-color:#E74C3C}.btn-danger:not(:disabled):not(.disabled):active,.btn-danger:not(:disabled):not(.disabled).active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#d62c1a;border-color:#ca2a19}.btn-danger:not(:disabled):not(.disabled):active:focus,.btn-danger:not(:disabled):not(.disabled).active:focus,.show>.btn-danger.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(235,103,89,0.5);box-shadow:0 0 0 0.2rem rgba(235,103,89,0.5)}.btn-light{color:#222;background-color:#adb5bd;border-color:#adb5bd}.btn-light:hover{color:#fff;background-color:#98a2ac;border-color:#919ca6}.btn-light:focus,.btn-light.focus{color:#fff;background-color:#98a2ac;border-color:#919ca6;-webkit-box-shadow:0 0 0 0.2rem rgba(152,159,166,0.5);box-shadow:0 0 0 0.2rem rgba(152,159,166,0.5)}.btn-light.disabled,.btn-light:disabled{color:#222;background-color:#adb5bd;border-color:#adb5bd}.btn-light:not(:disabled):not(.disabled):active,.btn-light:not(:disabled):not(.disabled).active,.show>.btn-light.dropdown-toggle{color:#fff;background-color:#919ca6;border-color:#8a95a1}.btn-light:not(:disabled):not(.disabled):active:focus,.btn-light:not(:disabled):not(.disabled).active:focus,.show>.btn-light.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(152,159,166,0.5);box-shadow:0 0 0 0.2rem rgba(152,159,166,0.5)}.btn-dark{color:#fff;background-color:#303030;border-color:#303030}.btn-dark:hover{color:#fff;background-color:#1d1d1d;border-color:#171616}.btn-dark:focus,.btn-dark.focus{color:#fff;background-color:#1d1d1d;border-color:#171616;-webkit-box-shadow:0 0 0 0.2rem rgba(79,79,79,0.5);box-shadow:0 0 0 0.2rem rgba(79,79,79,0.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#303030;border-color:#303030}.btn-dark:not(:disabled):not(.disabled):active,.btn-dark:not(:disabled):not(.disabled).active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#171616;border-color:#101010}.btn-dark:not(:disabled):not(.disabled):active:focus,.btn-dark:not(:disabled):not(.disabled).active:focus,.show>.btn-dark.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(79,79,79,0.5);box-shadow:0 0 0 0.2rem rgba(79,79,79,0.5)}.btn-outline-primary{color:#375a7f;border-color:#375a7f}.btn-outline-primary:hover{color:#fff;background-color:#375a7f;border-color:#375a7f}.btn-outline-primary:focus,.btn-outline-primary.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#375a7f;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled):active,.btn-outline-primary:not(:disabled):not(.disabled).active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#375a7f;border-color:#375a7f}.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5)}.btn-outline-secondary{color:#444;border-color:#444}.btn-outline-secondary:hover{color:#fff;background-color:#444;border-color:#444}.btn-outline-secondary:focus,.btn-outline-secondary.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5);box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#444;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled):active,.btn-outline-secondary:not(:disabled):not(.disabled).active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#444;border-color:#444}.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5);box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5)}.btn-outline-success{color:#00bc8c;border-color:#00bc8c}.btn-outline-success:hover{color:#fff;background-color:#00bc8c;border-color:#00bc8c}.btn-outline-success:focus,.btn-outline-success.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#00bc8c;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled):active,.btn-outline-success:not(:disabled):not(.disabled).active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#00bc8c;border-color:#00bc8c}.btn-outline-success:not(:disabled):not(.disabled):active:focus,.btn-outline-success:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-success.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5)}.btn-outline-info{color:#3498DB;border-color:#3498DB}.btn-outline-info:hover{color:#fff;background-color:#3498DB;border-color:#3498DB}.btn-outline-info:focus,.btn-outline-info.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5);box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#3498DB;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled):active,.btn-outline-info:not(:disabled):not(.disabled).active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#3498DB;border-color:#3498DB}.btn-outline-info:not(:disabled):not(.disabled):active:focus,.btn-outline-info:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-info.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5);box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5)}.btn-outline-warning{color:#F39C12;border-color:#F39C12}.btn-outline-warning:hover{color:#fff;background-color:#F39C12;border-color:#F39C12}.btn-outline-warning:focus,.btn-outline-warning.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5);box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#F39C12;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled):active,.btn-outline-warning:not(:disabled):not(.disabled).active,.show>.btn-outline-warning.dropdown-toggle{color:#fff;background-color:#F39C12;border-color:#F39C12}.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5);box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5)}.btn-outline-danger{color:#E74C3C;border-color:#E74C3C}.btn-outline-danger:hover{color:#fff;background-color:#E74C3C;border-color:#E74C3C}.btn-outline-danger:focus,.btn-outline-danger.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#E74C3C;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled):active,.btn-outline-danger:not(:disabled):not(.disabled).active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#E74C3C;border-color:#E74C3C}.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5)}.btn-outline-light{color:#adb5bd;border-color:#adb5bd}.btn-outline-light:hover{color:#222;background-color:#adb5bd;border-color:#adb5bd}.btn-outline-light:focus,.btn-outline-light.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5);box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#adb5bd;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled):active,.btn-outline-light:not(:disabled):not(.disabled).active,.show>.btn-outline-light.dropdown-toggle{color:#222;background-color:#adb5bd;border-color:#adb5bd}.btn-outline-light:not(:disabled):not(.disabled):active:focus,.btn-outline-light:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-light.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5);box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5)}.btn-outline-dark{color:#303030;border-color:#303030}.btn-outline-dark:hover{color:#fff;background-color:#303030;border-color:#303030}.btn-outline-dark:focus,.btn-outline-dark.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5);box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#303030;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled):active,.btn-outline-dark:not(:disabled):not(.disabled).active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#303030;border-color:#303030}.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5);box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5)}.btn-link{font-weight:400;color:#00bc8c;text-decoration:none}.btn-link:hover{color:#007053;text-decoration:underline}.btn-link:focus,.btn-link.focus{text-decoration:underline}.btn-link:disabled,.btn-link.disabled{color:#888;pointer-events:none}.btn-lg,.btn-group-lg>.btn{padding:0.5rem 1rem;font-size:1.171875rem;line-height:1.5;border-radius:0.3rem}.btn-sm,.btn-group-sm>.btn{padding:0.25rem 0.5rem;font-size:0.8203125rem;line-height:1.5;border-radius:0.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:0.5rem}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.fade{-webkit-transition:opacity 0.15s linear;transition:opacity 0.15s linear}@media (prefers-reduced-motion: reduce){.fade{-webkit-transition:none;transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition:height 0.35s ease;transition:height 0.35s ease}@media (prefers-reduced-motion: reduce){.collapsing{-webkit-transition:none;transition:none}}.dropup,.dropright,.dropdown,.dropleft{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:0.255em;vertical-align:0.255em;content:"";border-top:0.3em solid;border-right:0.3em solid transparent;border-bottom:0;border-left:0.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:0.5rem 0;margin:0.125rem 0 0;font-size:0.9375rem;color:#fff;text-align:left;list-style:none;background-color:#222;background-clip:padding-box;border:1px solid #444;border-radius:0.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width: 576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width: 768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width: 992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width: 1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:0.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:0.255em;vertical-align:0.255em;content:"";border-top:0;border-right:0.3em solid transparent;border-bottom:0.3em solid;border-left:0.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:0.125rem}.dropright .dropdown-toggle::after{display:inline-block;margin-left:0.255em;vertical-align:0.255em;content:"";border-top:0.3em solid transparent;border-right:0;border-bottom:0.3em solid transparent;border-left:0.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:0.125rem}.dropleft .dropdown-toggle::after{display:inline-block;margin-left:0.255em;vertical-align:0.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;margin-right:0.255em;vertical-align:0.255em;content:"";border-top:0.3em solid transparent;border-right:0.3em solid;border-bottom:0.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^="top"],.dropdown-menu[x-placement^="right"],.dropdown-menu[x-placement^="bottom"],.dropdown-menu[x-placement^="left"]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:0.5rem 0;overflow:hidden;border-top:1px solid #444}.dropdown-item{display:block;width:100%;padding:0.25rem 1.5rem;clear:both;font-weight:400;color:#fff;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:hover,.dropdown-item:focus{color:#fff;text-decoration:none;background-color:#375a7f}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#375a7f}.dropdown-item.disabled,.dropdown-item:disabled{color:#888;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:0.5rem 1.5rem;margin-bottom:0;font-size:0.8203125rem;color:#888;white-space:nowrap}.dropdown-item-text{display:block;padding:0.25rem 1.5rem;color:#fff}.btn-group,.btn-group-vertical{position:relative;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover{z-index:1}.btn-group>.btn:focus,.btn-group>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn.active{z-index:1}.btn-toolbar{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn:not(:first-child),.btn-group>.btn-group:not(:first-child){margin-left:-1px}.btn-group>.btn:not(:last-child):not(.dropdown-toggle),.btn-group>.btn-group:not(:last-child)>.btn{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:not(:first-child),.btn-group>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:0.5625rem;padding-left:0.5625rem}.dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-sm+.dropdown-toggle-split,.btn-group-sm>.btn+.dropdown-toggle-split{padding-right:0.375rem;padding-left:0.375rem}.btn-lg+.dropdown-toggle-split,.btn-group-lg>.btn+.dropdown-toggle-split{padding-right:0.75rem;padding-left:0.75rem}.btn-group-vertical{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle),.btn-group-vertical>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type="radio"],.btn-group-toggle>.btn input[type="checkbox"],.btn-group-toggle>.btn-group>.btn input[type="radio"],.btn-group-toggle>.btn-group>.btn input[type="checkbox"]{position:absolute;clip:rect(0, 0, 0, 0);pointer-events:none}.input-group{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-control-plaintext,.input-group>.custom-select,.input-group>.custom-file{position:relative;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;min-width:0;margin-bottom:0}.input-group>.form-control+.form-control,.input-group>.form-control+.custom-select,.input-group>.form-control+.custom-file,.input-group>.form-control-plaintext+.form-control,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.custom-file,.input-group>.custom-select+.form-control,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.custom-file,.input-group>.custom-file+.form-control,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.custom-file{margin-left:-1px}.input-group>.form-control:focus,.input-group>.custom-select:focus,.input-group>.custom-file .custom-file-input:focus ~ .custom-file-label{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.form-control:not(:last-child),.input-group>.custom-select:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.form-control:not(:first-child),.input-group>.custom-select:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-prepend,.input-group-append{display:-webkit-box;display:-ms-flexbox;display:flex}.input-group-prepend .btn,.input-group-append .btn{position:relative;z-index:2}.input-group-prepend .btn:focus,.input-group-append .btn:focus{z-index:3}.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.input-group-text,.input-group-append .input-group-text+.btn{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:0.375rem 0.75rem;margin-bottom:0;font-size:0.9375rem;font-weight:400;line-height:1.5;color:#adb5bd;text-align:center;white-space:nowrap;background-color:#444;border:1px solid #222;border-radius:0.25rem}.input-group-text input[type="radio"],.input-group-text input[type="checkbox"]{margin-top:0}.input-group-lg>.form-control:not(textarea),.input-group-lg>.custom-select{height:calc(1.5em + 1rem + 2px)}.input-group-lg>.form-control,.input-group-lg>.custom-select,.input-group-lg>.input-group-prepend>.input-group-text,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-append>.btn{padding:0.5rem 1rem;font-size:1.171875rem;line-height:1.5;border-radius:0.3rem}.input-group-sm>.form-control:not(textarea),.input-group-sm>.custom-select{height:calc(1.5em + 0.5rem + 2px)}.input-group-sm>.form-control,.input-group-sm>.custom-select,.input-group-sm>.input-group-prepend>.input-group-text,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-append>.btn{padding:0.25rem 0.5rem;font-size:0.8203125rem;line-height:1.5;border-radius:0.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text,.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;display:block;min-height:1.40625rem;padding-left:1.5rem}.custom-control-inline{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;left:0;z-index:-1;width:1rem;height:1.203125rem;opacity:0}.custom-control-input:checked ~ .custom-control-label::before{color:#fff;border-color:#375a7f;background-color:#375a7f}.custom-control-input:focus ~ .custom-control-label::before{-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-control-input:focus:not(:checked) ~ .custom-control-label::before{border-color:#739ac2}.custom-control-input:not(:disabled):active ~ .custom-control-label::before{color:#fff;background-color:#97b3d2;border-color:#97b3d2}.custom-control-input[disabled] ~ .custom-control-label,.custom-control-input:disabled ~ .custom-control-label{color:#888}.custom-control-input[disabled] ~ .custom-control-label::before,.custom-control-input:disabled ~ .custom-control-label::before{background-color:#ebebeb}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:0.203125rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#adb5bd solid 1px}.custom-control-label::after{position:absolute;top:0.203125rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50% / 50% 50%}.custom-checkbox .custom-control-label::before{border-radius:0.25rem}.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before{border-color:#375a7f;background-color:#375a7f}.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(55,90,127,0.5)}.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before{background-color:rgba(55,90,127,0.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(55,90,127,0.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:0.5rem}.custom-switch .custom-control-label::after{top:calc(0.203125rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#adb5bd;border-radius:0.5rem;-webkit-transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:transform 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:transform 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.custom-switch .custom-control-label::after{-webkit-transition:none;transition:none}}.custom-switch .custom-control-input:checked ~ .custom-control-label::after{background-color:#fff;-webkit-transform:translateX(0.75rem);transform:translateX(0.75rem)}.custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(55,90,127,0.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + 0.75rem + 2px);padding:0.375rem 1.75rem 0.375rem 0.75rem;font-size:0.9375rem;font-weight:400;line-height:1.5;color:#444;vertical-align:middle;background:#fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23303030' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px;border:1px solid #222;border-radius:0.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#739ac2;outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-select:focus::-ms-value{color:#444;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:0.75rem;background-image:none}.custom-select:disabled{color:#888;background-color:#ebebeb}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #444}.custom-select-sm{height:calc(1.5em + 0.5rem + 2px);padding-top:0.25rem;padding-bottom:0.25rem;padding-left:0.5rem;font-size:0.8203125rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:0.5rem;padding-bottom:0.5rem;padding-left:1rem;font-size:1.171875rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + 0.75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + 0.75rem + 2px);margin:0;opacity:0}.custom-file-input:focus ~ .custom-file-label{border-color:#739ac2;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-file-input[disabled] ~ .custom-file-label,.custom-file-input:disabled ~ .custom-file-label{background-color:#ebebeb}.custom-file-input:lang(en) ~ .custom-file-label::after{content:"Browse"}.custom-file-input ~ .custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + 0.75rem + 2px);padding:0.375rem 0.75rem;font-weight:400;line-height:1.5;color:#adb5bd;background-color:#fff;border:1px solid #222;border-radius:0.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + 0.75rem);padding:0.375rem 0.75rem;line-height:1.5;color:#adb5bd;content:"Browse";background-color:#444;border-left:inherit;border-radius:0 0.25rem 0.25rem 0}.custom-range{width:100%;height:1.4rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:none}.custom-range:focus::-webkit-slider-thumb{-webkit-box-shadow:0 0 0 1px #222,0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 1px #222,0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #222,0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #222,0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-0.25rem;background-color:#375a7f;border:0;border-radius:1rem;-webkit-transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#97b3d2}.custom-range::-webkit-slider-runnable-track{width:100%;height:0.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#375a7f;border:0;border-radius:1rem;-webkit-transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-moz-range-thumb{-webkit-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#97b3d2}.custom-range::-moz-range-track{width:100%;height:0.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:0.2rem;margin-left:0.2rem;background-color:#375a7f;border:0;border-radius:1rem;-webkit-transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-ms-thumb{-webkit-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#97b3d2}.custom-range::-ms-track{width:100%;height:0.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:0.5rem}.custom-range::-ms-fill-lower{background-color:#dee2e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dee2e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#adb5bd}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#adb5bd}.custom-control-label::before,.custom-file-label,.custom-select{-webkit-transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.custom-control-label::before,.custom-file-label,.custom-select{-webkit-transition:none;transition:none}}.nav{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:0.5rem 2rem}.nav-link:hover,.nav-link:focus{text-decoration:none}.nav-link.disabled{color:#adb5bd;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #444}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:0.25rem;border-top-right-radius:0.25rem}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{border-color:#444 #444 transparent}.nav-tabs .nav-link.disabled{color:#adb5bd;background-color:transparent;border-color:transparent}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{color:#fff;background-color:#222;border-color:#444 #444 transparent}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:0.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#375a7f}.nav-fill .nav-item{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem}.navbar .container,.navbar .container-fluid,.navbar .container-sm,.navbar .container-md,.navbar .container-lg,.navbar .container-xl{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:0.32421875rem;padding-bottom:0.32421875rem;margin-right:1rem;font-size:1.171875rem;line-height:inherit;white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}.navbar-nav{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:0.5rem;padding-bottom:0.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:0.25rem 0.75rem;font-size:1.171875rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:0.25rem}.navbar-toggler:hover,.navbar-toggler:focus{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width: 575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-xl{padding-right:0;padding-left:0}}@media (min-width: 576px){.navbar-expand-sm{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:0.5rem;padding-left:0.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width: 767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-md,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-xl{padding-right:0;padding-left:0}}@media (min-width: 768px){.navbar-expand-md{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:0.5rem;padding-left:0.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-md,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width: 991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-xl{padding-right:0;padding-left:0}}@media (min-width: 992px){.navbar-expand-lg{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:0.5rem;padding-left:0.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width: 1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-xl{padding-right:0;padding-left:0}}@media (min-width: 1200px){.navbar-expand-xl{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:0.5rem;padding-left:0.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-sm,.navbar-expand>.container-md,.navbar-expand>.container-lg,.navbar-expand>.container-xl{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:0.5rem;padding-left:0.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-sm,.navbar-expand>.container-md,.navbar-expand>.container-lg,.navbar-expand>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:#222}.navbar-light .navbar-brand:hover,.navbar-light .navbar-brand:focus{color:#222}.navbar-light .navbar-nav .nav-link{color:rgba(34,34,34,0.7)}.navbar-light .navbar-nav .nav-link:hover,.navbar-light .navbar-nav .nav-link:focus{color:#222}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,0.3)}.navbar-light .navbar-nav .show>.nav-link,.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .nav-link.active{color:#222}.navbar-light .navbar-toggler{color:rgba(34,34,34,0.7);border-color:rgba(34,34,34,0.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2834, 34, 34, 0.7%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(34,34,34,0.7)}.navbar-light .navbar-text a{color:#222}.navbar-light .navbar-text a:hover,.navbar-light .navbar-text a:focus{color:#222}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:hover,.navbar-dark .navbar-brand:focus{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,0.6)}.navbar-dark .navbar-nav .nav-link:hover,.navbar-dark .navbar-nav .nav-link:focus{color:#fff}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,0.25)}.navbar-dark .navbar-nav .show>.nav-link,.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .nav-link.active{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,0.6);border-color:rgba(255,255,255,0.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.6%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,0.6)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:hover,.navbar-dark .navbar-text a:focus{color:#fff}.card{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#303030;background-clip:border-box;border:1px solid rgba(0,0,0,0.125);border-radius:0.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(0.25rem - 1px);border-top-right-radius:calc(0.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(0.25rem - 1px);border-bottom-left-radius:calc(0.25rem - 1px)}.card-body{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:0.75rem}.card-subtitle{margin-top:-0.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:0.75rem 1.25rem;margin-bottom:0;background-color:#444;border-bottom:1px solid rgba(0,0,0,0.125)}.card-header:first-child{border-radius:calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0}.card-header+.list-group .list-group-item:first-child{border-top:0}.card-footer{padding:0.75rem 1.25rem;background-color:#444;border-top:1px solid rgba(0,0,0,0.125)}.card-footer:last-child{border-radius:0 0 calc(0.25rem - 1px) calc(0.25rem - 1px)}.card-header-tabs{margin-right:-0.625rem;margin-bottom:-0.75rem;margin-left:-0.625rem;border-bottom:0}.card-header-pills{margin-right:-0.625rem;margin-left:-0.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem}.card-img,.card-img-top,.card-img-bottom{-ms-flex-negative:0;flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:calc(0.25rem - 1px);border-top-right-radius:calc(0.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(0.25rem - 1px);border-bottom-left-radius:calc(0.25rem - 1px)}.card-deck .card{margin-bottom:15px}@media (min-width: 576px){.card-deck{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{-webkit-box-flex:1;-ms-flex:1 0 0%;flex:1 0 0%;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group>.card{margin-bottom:15px}@media (min-width: 576px){.card-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-webkit-box-flex:1;-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-img-top,.card-group>.card:not(:last-child) .card-header{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-img-bottom,.card-group>.card:not(:last-child) .card-footer{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-img-top,.card-group>.card:not(:first-child) .card-header{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-img-bottom,.card-group>.card:not(:first-child) .card-footer{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:0.75rem}@media (min-width: 576px){.card-columns{-webkit-column-count:3;column-count:3;-webkit-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:0.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#444;border-radius:0.25rem}.breadcrumb-item{display:-webkit-box;display:-ms-flexbox;display:flex}.breadcrumb-item+.breadcrumb-item{padding-left:0.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:0.5rem;color:#888;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#888}.pagination{display:-webkit-box;display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:0.25rem}.page-link{position:relative;display:block;padding:0.5rem 0.75rem;margin-left:0;line-height:1.25;color:#fff;background-color:#00bc8c;border:0 solid transparent}.page-link:hover{z-index:2;color:#fff;text-decoration:none;background-color:#00efb2;border-color:transparent}.page-link:focus{z-index:3;outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:0.25rem;border-bottom-left-radius:0.25rem}.page-item:last-child .page-link{border-top-right-radius:0.25rem;border-bottom-right-radius:0.25rem}.page-item.active .page-link{z-index:3;color:#fff;background-color:#00efb2;border-color:transparent}.page-item.disabled .page-link{color:#fff;pointer-events:none;cursor:auto;background-color:#007053;border-color:transparent}.pagination-lg .page-link{padding:0.75rem 1.5rem;font-size:1.171875rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:0.3rem;border-bottom-left-radius:0.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:0.3rem;border-bottom-right-radius:0.3rem}.pagination-sm .page-link{padding:0.25rem 0.5rem;font-size:0.8203125rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:0.2rem;border-bottom-left-radius:0.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:0.2rem;border-bottom-right-radius:0.2rem}.badge{display:inline-block;padding:0.25em 0.4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:0.25rem;-webkit-transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.badge{-webkit-transition:none;transition:none}}a.badge:hover,a.badge:focus{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:0.6em;padding-left:0.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#375a7f}a.badge-primary:hover,a.badge-primary:focus{color:#fff;background-color:#28415b}a.badge-primary:focus,a.badge-primary.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5)}.badge-secondary{color:#fff;background-color:#444}a.badge-secondary:hover,a.badge-secondary:focus{color:#fff;background-color:#2b2a2a}a.badge-secondary:focus,a.badge-secondary.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5);box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5)}.badge-success{color:#fff;background-color:#00bc8c}a.badge-success:hover,a.badge-success:focus{color:#fff;background-color:#008966}a.badge-success:focus,a.badge-success.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5)}.badge-info{color:#fff;background-color:#3498DB}a.badge-info:hover,a.badge-info:focus{color:#fff;background-color:#217dbb}a.badge-info:focus,a.badge-info.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5);box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5)}.badge-warning{color:#fff;background-color:#F39C12}a.badge-warning:hover,a.badge-warning:focus{color:#fff;background-color:#c87f0a}a.badge-warning:focus,a.badge-warning.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5);box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5)}.badge-danger{color:#fff;background-color:#E74C3C}a.badge-danger:hover,a.badge-danger:focus{color:#fff;background-color:#d62c1a}a.badge-danger:focus,a.badge-danger.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5)}.badge-light{color:#222;background-color:#adb5bd}a.badge-light:hover,a.badge-light:focus{color:#222;background-color:#919ca6}a.badge-light:focus,a.badge-light.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5);box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5)}.badge-dark{color:#fff;background-color:#303030}a.badge-dark:hover,a.badge-dark:focus{color:#fff;background-color:#171616}a.badge-dark:focus,a.badge-dark.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5);box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#303030;border-radius:0.3rem}@media (min-width: 576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:0.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:0.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3.90625rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:0.75rem 1.25rem;color:inherit}.alert-primary{color:#1d2f42;background-color:#d7dee5;border-color:#c7d1db}.alert-primary hr{border-top-color:#b7c4d1}.alert-primary .alert-link{color:#0d161f}.alert-secondary{color:#232323;background-color:#dadada;border-color:#cbcbcb}.alert-secondary hr{border-top-color:#bebebe}.alert-secondary .alert-link{color:#0a0909}.alert-success{color:#006249;background-color:#ccf2e8;border-color:#b8ecdf}.alert-success hr{border-top-color:#a4e7d6}.alert-success .alert-link{color:#002f23}.alert-info{color:#1b4f72;background-color:#d6eaf8;border-color:#c6e2f5}.alert-info hr{border-top-color:#b0d7f1}.alert-info .alert-link{color:#113249}.alert-warning{color:#7e5109;background-color:#fdebd0;border-color:#fce3bd}.alert-warning hr{border-top-color:#fbd9a5}.alert-warning .alert-link{color:#4e3206}.alert-danger{color:#78281f;background-color:#fadbd8;border-color:#f8cdc8}.alert-danger hr{border-top-color:#f5b8b1}.alert-danger .alert-link{color:#4f1a15}.alert-light{color:#5a5e62;background-color:#eff0f2;border-color:#e8eaed}.alert-light hr{border-top-color:#dadde2}.alert-light .alert-link{color:#424547}.alert-dark{color:#191919;background-color:#d6d6d6;border-color:#c5c5c5}.alert-dark hr{border-top-color:#b8b8b8}.alert-dark .alert-link{color:black}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-webkit-box;display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;line-height:0;font-size:0.703125rem;background-color:#444;border-radius:0.25rem}.progress-bar{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#375a7f;-webkit-transition:width 0.6s ease;transition:width 0.6s ease}@media (prefers-reduced-motion: reduce){.progress-bar{-webkit-transition:none;transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion: reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.media-body{-webkit-box-flex:1;-ms-flex:1;flex:1}.list-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:0.25rem}.list-group-item-action{width:100%;color:#444;text-align:inherit}.list-group-item-action:hover,.list-group-item-action:focus{z-index:1;color:#444;text-decoration:none;background-color:#444}.list-group-item-action:active{color:#fff;background-color:#ebebeb}.list-group-item{position:relative;display:block;padding:0.75rem 1.25rem;background-color:#303030;border:1px solid #444}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#888;pointer-events:none;background-color:#303030}.list-group-item.active{z-index:2;color:#fff;background-color:#375a7f;border-color:#375a7f}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:0.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:0.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width: 576px){.list-group-horizontal-sm{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:0.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:0.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width: 768px){.list-group-horizontal-md{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:0.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:0.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width: 992px){.list-group-horizontal-lg{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:0.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:0.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width: 1200px){.list-group-horizontal-xl{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:0.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:0.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#1d2f42;background-color:#c7d1db}.list-group-item-primary.list-group-item-action:hover,.list-group-item-primary.list-group-item-action:focus{color:#1d2f42;background-color:#b7c4d1}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#1d2f42;border-color:#1d2f42}.list-group-item-secondary{color:#232323;background-color:#cbcbcb}.list-group-item-secondary.list-group-item-action:hover,.list-group-item-secondary.list-group-item-action:focus{color:#232323;background-color:#bebebe}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#232323;border-color:#232323}.list-group-item-success{color:#006249;background-color:#b8ecdf}.list-group-item-success.list-group-item-action:hover,.list-group-item-success.list-group-item-action:focus{color:#006249;background-color:#a4e7d6}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#006249;border-color:#006249}.list-group-item-info{color:#1b4f72;background-color:#c6e2f5}.list-group-item-info.list-group-item-action:hover,.list-group-item-info.list-group-item-action:focus{color:#1b4f72;background-color:#b0d7f1}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#1b4f72;border-color:#1b4f72}.list-group-item-warning{color:#7e5109;background-color:#fce3bd}.list-group-item-warning.list-group-item-action:hover,.list-group-item-warning.list-group-item-action:focus{color:#7e5109;background-color:#fbd9a5}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#7e5109;border-color:#7e5109}.list-group-item-danger{color:#78281f;background-color:#f8cdc8}.list-group-item-danger.list-group-item-action:hover,.list-group-item-danger.list-group-item-action:focus{color:#78281f;background-color:#f5b8b1}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#78281f;border-color:#78281f}.list-group-item-light{color:#5a5e62;background-color:#e8eaed}.list-group-item-light.list-group-item-action:hover,.list-group-item-light.list-group-item-action:focus{color:#5a5e62;background-color:#dadde2}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#5a5e62;border-color:#5a5e62}.list-group-item-dark{color:#191919;background-color:#c5c5c5}.list-group-item-dark.list-group-item-action:hover,.list-group-item-dark.list-group-item-action:focus{color:#191919;background-color:#b8b8b8}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#191919;border-color:#191919}.close{float:right;font-size:1.40625rem;font-weight:700;line-height:1;color:#fff;text-shadow:none;opacity:.5}.close:hover{color:#fff;text-decoration:none}.close:not(:disabled):not(.disabled):hover,.close:not(:disabled):not(.disabled):focus{opacity:.75}button.close{padding:0;background-color:transparent;border:0}a.close.disabled{pointer-events:none}.toast{max-width:350px;overflow:hidden;font-size:0.875rem;background-color:#444;background-clip:padding-box;border:1px solid rgba(0,0,0,0.1);-webkit-box-shadow:0 0.25rem 0.75rem rgba(0,0,0,0.1);box-shadow:0 0.25rem 0.75rem rgba(0,0,0,0.1);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);opacity:0;border-radius:0.25rem}.toast:not(:last-child){margin-bottom:0.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:0.25rem 0.75rem;color:#888;background-color:#303030;background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,0.05)}.toast-body{padding:0.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:0.5rem;pointer-events:none}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform 0.3s ease-out;transition:-webkit-transform 0.3s ease-out;transition:transform 0.3s ease-out;transition:transform 0.3s ease-out, -webkit-transform 0.3s ease-out;-webkit-transform:translate(0, -50px);transform:translate(0, -50px)}@media (prefers-reduced-motion: reduce){.modal.fade .modal-dialog{-webkit-transition:none;transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal.modal-static .modal-dialog{-webkit-transform:scale(1.02);transform:scale(1.02)}.modal-dialog-scrollable{display:-webkit-box;display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-header,.modal-dialog-scrollable .modal-footer{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);height:-webkit-min-content;height:-moz-min-content;height:min-content;content:""}.modal-dialog-centered.modal-dialog-scrollable{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#303030;background-clip:padding-box;border:1px solid #444;border-radius:0.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:0.5}.modal-header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #444;border-top-left-radius:calc(0.3rem - 1px);border-top-right-radius:calc(0.3rem - 1px)}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;padding:0.75rem;border-top:1px solid #444;border-bottom-right-radius:calc(0.3rem - 1px);border-bottom-left-radius:calc(0.3rem - 1px)}.modal-footer>*{margin:0.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width: 576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem);height:-webkit-min-content;height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width: 992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width: 1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:"Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:0.8203125rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:0.9}.tooltip .arrow{position:absolute;display:block;width:0.8rem;height:0.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-top,.bs-tooltip-auto[x-placement^="top"]{padding:0.4rem 0}.bs-tooltip-top .arrow,.bs-tooltip-auto[x-placement^="top"] .arrow{bottom:0}.bs-tooltip-top .arrow::before,.bs-tooltip-auto[x-placement^="top"] .arrow::before{top:0;border-width:0.4rem 0.4rem 0;border-top-color:#000}.bs-tooltip-right,.bs-tooltip-auto[x-placement^="right"]{padding:0 0.4rem}.bs-tooltip-right .arrow,.bs-tooltip-auto[x-placement^="right"] .arrow{left:0;width:0.4rem;height:0.8rem}.bs-tooltip-right .arrow::before,.bs-tooltip-auto[x-placement^="right"] .arrow::before{right:0;border-width:0.4rem 0.4rem 0.4rem 0;border-right-color:#000}.bs-tooltip-bottom,.bs-tooltip-auto[x-placement^="bottom"]{padding:0.4rem 0}.bs-tooltip-bottom .arrow,.bs-tooltip-auto[x-placement^="bottom"] .arrow{top:0}.bs-tooltip-bottom .arrow::before,.bs-tooltip-auto[x-placement^="bottom"] .arrow::before{bottom:0;border-width:0 0.4rem 0.4rem;border-bottom-color:#000}.bs-tooltip-left,.bs-tooltip-auto[x-placement^="left"]{padding:0 0.4rem}.bs-tooltip-left .arrow,.bs-tooltip-auto[x-placement^="left"] .arrow{right:0;width:0.4rem;height:0.8rem}.bs-tooltip-left .arrow::before,.bs-tooltip-auto[x-placement^="left"] .arrow::before{left:0;border-width:0.4rem 0 0.4rem 0.4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:0.25rem 0.5rem;color:#fff;text-align:center;background-color:#000;border-radius:0.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:"Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:0.8203125rem;word-wrap:break-word;background-color:#303030;background-clip:padding-box;border:1px solid rgba(0,0,0,0.2);border-radius:0.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:0.5rem;margin:0 0.3rem}.popover .arrow::before,.popover .arrow::after{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-top,.bs-popover-auto[x-placement^="top"]{margin-bottom:0.5rem}.bs-popover-top>.arrow,.bs-popover-auto[x-placement^="top"]>.arrow{bottom:calc(-0.5rem - 1px)}.bs-popover-top>.arrow::before,.bs-popover-auto[x-placement^="top"]>.arrow::before{bottom:0;border-width:0.5rem 0.5rem 0;border-top-color:rgba(0,0,0,0.25)}.bs-popover-top>.arrow::after,.bs-popover-auto[x-placement^="top"]>.arrow::after{bottom:1px;border-width:0.5rem 0.5rem 0;border-top-color:#303030}.bs-popover-right,.bs-popover-auto[x-placement^="right"]{margin-left:0.5rem}.bs-popover-right>.arrow,.bs-popover-auto[x-placement^="right"]>.arrow{left:calc(-0.5rem - 1px);width:0.5rem;height:1rem;margin:0.3rem 0}.bs-popover-right>.arrow::before,.bs-popover-auto[x-placement^="right"]>.arrow::before{left:0;border-width:0.5rem 0.5rem 0.5rem 0;border-right-color:rgba(0,0,0,0.25)}.bs-popover-right>.arrow::after,.bs-popover-auto[x-placement^="right"]>.arrow::after{left:1px;border-width:0.5rem 0.5rem 0.5rem 0;border-right-color:#303030}.bs-popover-bottom,.bs-popover-auto[x-placement^="bottom"]{margin-top:0.5rem}.bs-popover-bottom>.arrow,.bs-popover-auto[x-placement^="bottom"]>.arrow{top:calc(-0.5rem - 1px)}.bs-popover-bottom>.arrow::before,.bs-popover-auto[x-placement^="bottom"]>.arrow::before{top:0;border-width:0 0.5rem 0.5rem 0.5rem;border-bottom-color:rgba(0,0,0,0.25)}.bs-popover-bottom>.arrow::after,.bs-popover-auto[x-placement^="bottom"]>.arrow::after{top:1px;border-width:0 0.5rem 0.5rem 0.5rem;border-bottom-color:#303030}.bs-popover-bottom .popover-header::before,.bs-popover-auto[x-placement^="bottom"] .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-0.5rem;content:"";border-bottom:1px solid #444}.bs-popover-left,.bs-popover-auto[x-placement^="left"]{margin-right:0.5rem}.bs-popover-left>.arrow,.bs-popover-auto[x-placement^="left"]>.arrow{right:calc(-0.5rem - 1px);width:0.5rem;height:1rem;margin:0.3rem 0}.bs-popover-left>.arrow::before,.bs-popover-auto[x-placement^="left"]>.arrow::before{right:0;border-width:0.5rem 0 0.5rem 0.5rem;border-left-color:rgba(0,0,0,0.25)}.bs-popover-left>.arrow::after,.bs-popover-auto[x-placement^="left"]>.arrow::after{right:1px;border-width:0.5rem 0 0.5rem 0.5rem;border-left-color:#303030}.popover-header{padding:0.5rem 0.75rem;margin-bottom:0;font-size:0.9375rem;background-color:#444;border-bottom:1px solid #373737;border-top-left-radius:calc(0.3rem - 1px);border-top-right-radius:calc(0.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:0.5rem 0.75rem;color:#fff}.carousel{position:relative}.carousel.pointer-event{-ms-touch-action:pan-y;touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transition:-webkit-transform 0.6s ease-in-out;transition:-webkit-transform 0.6s ease-in-out;transition:transform 0.6s ease-in-out;transition:transform 0.6s ease-in-out, -webkit-transform 0.6s ease-in-out}@media (prefers-reduced-motion: reduce){.carousel-item{-webkit-transition:none;transition:none}}.carousel-item.active,.carousel-item-next,.carousel-item-prev{display:block}.carousel-item-next:not(.carousel-item-left),.active.carousel-item-right{-webkit-transform:translateX(100%);transform:translateX(100%)}.carousel-item-prev:not(.carousel-item-right),.active.carousel-item-left{-webkit-transform:translateX(-100%);transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;-webkit-transition-property:opacity;transition-property:opacity;-webkit-transform:none;transform:none}.carousel-fade .carousel-item.active,.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right{z-index:1;opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{z-index:0;opacity:0;-webkit-transition:opacity 0s 0.6s;transition:opacity 0s 0.6s}@media (prefers-reduced-motion: reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{-webkit-transition:none;transition:none}}.carousel-control-prev,.carousel-control-next{position:absolute;top:0;bottom:0;z-index:1;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;opacity:0.5;-webkit-transition:opacity 0.15s ease;transition:opacity 0.15s ease}@media (prefers-reduced-motion: reduce){.carousel-control-prev,.carousel-control-next{-webkit-transition:none;transition:none}}.carousel-control-prev:hover,.carousel-control-prev:focus,.carousel-control-next:hover,.carousel-control-next:focus{color:#fff;text-decoration:none;outline:0;opacity:0.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-prev-icon,.carousel-control-next-icon{display:inline-block;width:20px;height:20px;background:no-repeat 50% / 100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:15;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{-webkit-box-sizing:content-box;box-sizing:content-box;-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;-webkit-transition:opacity 0.6s ease;transition:opacity 0.6s ease}@media (prefers-reduced-motion: reduce){.carousel-indicators li{-webkit-transition:none;transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:0.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:0.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.align-baseline{vertical-align:baseline !important}.align-top{vertical-align:top !important}.align-middle{vertical-align:middle !important}.align-bottom{vertical-align:bottom !important}.align-text-bottom{vertical-align:text-bottom !important}.align-text-top{vertical-align:text-top !important}.bg-primary{background-color:#375a7f !important}a.bg-primary:hover,a.bg-primary:focus,button.bg-primary:hover,button.bg-primary:focus{background-color:#28415b !important}.bg-secondary{background-color:#444 !important}a.bg-secondary:hover,a.bg-secondary:focus,button.bg-secondary:hover,button.bg-secondary:focus{background-color:#2b2a2a !important}.bg-success{background-color:#00bc8c !important}a.bg-success:hover,a.bg-success:focus,button.bg-success:hover,button.bg-success:focus{background-color:#008966 !important}.bg-info{background-color:#3498DB !important}a.bg-info:hover,a.bg-info:focus,button.bg-info:hover,button.bg-info:focus{background-color:#217dbb !important}.bg-warning{background-color:#F39C12 !important}a.bg-warning:hover,a.bg-warning:focus,button.bg-warning:hover,button.bg-warning:focus{background-color:#c87f0a !important}.bg-danger{background-color:#E74C3C !important}a.bg-danger:hover,a.bg-danger:focus,button.bg-danger:hover,button.bg-danger:focus{background-color:#d62c1a !important}.bg-light{background-color:#adb5bd !important}a.bg-light:hover,a.bg-light:focus,button.bg-light:hover,button.bg-light:focus{background-color:#919ca6 !important}.bg-dark{background-color:#303030 !important}a.bg-dark:hover,a.bg-dark:focus,button.bg-dark:hover,button.bg-dark:focus{background-color:#171616 !important}.bg-white{background-color:#fff !important}.bg-transparent{background-color:transparent !important}.border{border:1px solid #dee2e6 !important}.border-top{border-top:1px solid #dee2e6 !important}.border-right{border-right:1px solid #dee2e6 !important}.border-bottom{border-bottom:1px solid #dee2e6 !important}.border-left{border-left:1px solid #dee2e6 !important}.border-0{border:0 !important}.border-top-0{border-top:0 !important}.border-right-0{border-right:0 !important}.border-bottom-0{border-bottom:0 !important}.border-left-0{border-left:0 !important}.border-primary{border-color:#375a7f !important}.border-secondary{border-color:#444 !important}.border-success{border-color:#00bc8c !important}.border-info{border-color:#3498DB !important}.border-warning{border-color:#F39C12 !important}.border-danger{border-color:#E74C3C !important}.border-light{border-color:#adb5bd !important}.border-dark{border-color:#303030 !important}.border-white{border-color:#fff !important}.rounded-sm{border-radius:0.2rem !important}.rounded{border-radius:0.25rem !important}.rounded-top{border-top-left-radius:0.25rem !important;border-top-right-radius:0.25rem !important}.rounded-right{border-top-right-radius:0.25rem !important;border-bottom-right-radius:0.25rem !important}.rounded-bottom{border-bottom-right-radius:0.25rem !important;border-bottom-left-radius:0.25rem !important}.rounded-left{border-top-left-radius:0.25rem !important;border-bottom-left-radius:0.25rem !important}.rounded-lg{border-radius:0.3rem !important}.rounded-circle{border-radius:50% !important}.rounded-pill{border-radius:50rem !important}.rounded-0{border-radius:0 !important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none !important}.d-inline{display:inline !important}.d-inline-block{display:inline-block !important}.d-block{display:block !important}.d-table{display:table !important}.d-table-row{display:table-row !important}.d-table-cell{display:table-cell !important}.d-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}@media (min-width: 576px){.d-sm-none{display:none !important}.d-sm-inline{display:inline !important}.d-sm-inline-block{display:inline-block !important}.d-sm-block{display:block !important}.d-sm-table{display:table !important}.d-sm-table-row{display:table-row !important}.d-sm-table-cell{display:table-cell !important}.d-sm-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-sm-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 768px){.d-md-none{display:none !important}.d-md-inline{display:inline !important}.d-md-inline-block{display:inline-block !important}.d-md-block{display:block !important}.d-md-table{display:table !important}.d-md-table-row{display:table-row !important}.d-md-table-cell{display:table-cell !important}.d-md-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-md-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 992px){.d-lg-none{display:none !important}.d-lg-inline{display:inline !important}.d-lg-inline-block{display:inline-block !important}.d-lg-block{display:block !important}.d-lg-table{display:table !important}.d-lg-table-row{display:table-row !important}.d-lg-table-cell{display:table-cell !important}.d-lg-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-lg-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 1200px){.d-xl-none{display:none !important}.d-xl-inline{display:inline !important}.d-xl-inline-block{display:inline-block !important}.d-xl-block{display:block !important}.d-xl-table{display:table !important}.d-xl-table-row{display:table-row !important}.d-xl-table-cell{display:table-cell !important}.d-xl-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-xl-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}}@media print{.d-print-none{display:none !important}.d-print-inline{display:inline !important}.d-print-inline-block{display:inline-block !important}.d-print-block{display:block !important}.d-print-table{display:table !important}.d-print-table-row{display:table-row !important}.d-print-table-cell{display:table-cell !important}.d-print-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-print-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive iframe,.embed-responsive embed,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.8571428571%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-webkit-box-orient:horizontal !important;-webkit-box-direction:normal !important;-ms-flex-direction:row !important;flex-direction:row !important}.flex-column{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.flex-row-reverse{-webkit-box-orient:horizontal !important;-webkit-box-direction:reverse !important;-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-column-reverse{-webkit-box-orient:vertical !important;-webkit-box-direction:reverse !important;-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-fill{-webkit-box-flex:1 !important;-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-grow-0{-webkit-box-flex:0 !important;-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-grow-1{-webkit-box-flex:1 !important;-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-start{-webkit-box-pack:start !important;-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-end{-webkit-box-pack:end !important;-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-center{-webkit-box-pack:center !important;-ms-flex-pack:center !important;justify-content:center !important}.justify-content-between{-webkit-box-pack:justify !important;-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-start{-webkit-box-align:start !important;-ms-flex-align:start !important;align-items:flex-start !important}.align-items-end{-webkit-box-align:end !important;-ms-flex-align:end !important;align-items:flex-end !important}.align-items-center{-webkit-box-align:center !important;-ms-flex-align:center !important;align-items:center !important}.align-items-baseline{-webkit-box-align:baseline !important;-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-stretch{-webkit-box-align:stretch !important;-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}@media (min-width: 576px){.flex-sm-row{-webkit-box-orient:horizontal !important;-webkit-box-direction:normal !important;-ms-flex-direction:row !important;flex-direction:row !important}.flex-sm-column{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.flex-sm-row-reverse{-webkit-box-orient:horizontal !important;-webkit-box-direction:reverse !important;-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-sm-column-reverse{-webkit-box-orient:vertical !important;-webkit-box-direction:reverse !important;-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-sm-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-sm-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-sm-fill{-webkit-box-flex:1 !important;-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-sm-grow-0{-webkit-box-flex:0 !important;-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-sm-grow-1{-webkit-box-flex:1 !important;-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-sm-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-sm-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-sm-start{-webkit-box-pack:start !important;-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-sm-end{-webkit-box-pack:end !important;-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-sm-center{-webkit-box-pack:center !important;-ms-flex-pack:center !important;justify-content:center !important}.justify-content-sm-between{-webkit-box-pack:justify !important;-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-sm-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-sm-start{-webkit-box-align:start !important;-ms-flex-align:start !important;align-items:flex-start !important}.align-items-sm-end{-webkit-box-align:end !important;-ms-flex-align:end !important;align-items:flex-end !important}.align-items-sm-center{-webkit-box-align:center !important;-ms-flex-align:center !important;align-items:center !important}.align-items-sm-baseline{-webkit-box-align:baseline !important;-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-sm-stretch{-webkit-box-align:stretch !important;-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-sm-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-sm-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-sm-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-sm-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-sm-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-sm-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-sm-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-sm-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-sm-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-sm-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-sm-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-sm-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 768px){.flex-md-row{-webkit-box-orient:horizontal !important;-webkit-box-direction:normal !important;-ms-flex-direction:row !important;flex-direction:row !important}.flex-md-column{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.flex-md-row-reverse{-webkit-box-orient:horizontal !important;-webkit-box-direction:reverse !important;-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-md-column-reverse{-webkit-box-orient:vertical !important;-webkit-box-direction:reverse !important;-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-md-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-md-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-md-fill{-webkit-box-flex:1 !important;-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-md-grow-0{-webkit-box-flex:0 !important;-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-md-grow-1{-webkit-box-flex:1 !important;-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-md-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-md-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-md-start{-webkit-box-pack:start !important;-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-md-end{-webkit-box-pack:end !important;-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-md-center{-webkit-box-pack:center !important;-ms-flex-pack:center !important;justify-content:center !important}.justify-content-md-between{-webkit-box-pack:justify !important;-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-md-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-md-start{-webkit-box-align:start !important;-ms-flex-align:start !important;align-items:flex-start !important}.align-items-md-end{-webkit-box-align:end !important;-ms-flex-align:end !important;align-items:flex-end !important}.align-items-md-center{-webkit-box-align:center !important;-ms-flex-align:center !important;align-items:center !important}.align-items-md-baseline{-webkit-box-align:baseline !important;-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-md-stretch{-webkit-box-align:stretch !important;-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-md-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-md-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-md-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-md-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-md-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-md-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-md-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-md-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-md-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-md-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-md-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-md-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 992px){.flex-lg-row{-webkit-box-orient:horizontal !important;-webkit-box-direction:normal !important;-ms-flex-direction:row !important;flex-direction:row !important}.flex-lg-column{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.flex-lg-row-reverse{-webkit-box-orient:horizontal !important;-webkit-box-direction:reverse !important;-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-lg-column-reverse{-webkit-box-orient:vertical !important;-webkit-box-direction:reverse !important;-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-lg-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-lg-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-lg-fill{-webkit-box-flex:1 !important;-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-lg-grow-0{-webkit-box-flex:0 !important;-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-lg-grow-1{-webkit-box-flex:1 !important;-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-lg-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-lg-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-lg-start{-webkit-box-pack:start !important;-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-lg-end{-webkit-box-pack:end !important;-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-lg-center{-webkit-box-pack:center !important;-ms-flex-pack:center !important;justify-content:center !important}.justify-content-lg-between{-webkit-box-pack:justify !important;-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-lg-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-lg-start{-webkit-box-align:start !important;-ms-flex-align:start !important;align-items:flex-start !important}.align-items-lg-end{-webkit-box-align:end !important;-ms-flex-align:end !important;align-items:flex-end !important}.align-items-lg-center{-webkit-box-align:center !important;-ms-flex-align:center !important;align-items:center !important}.align-items-lg-baseline{-webkit-box-align:baseline !important;-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-lg-stretch{-webkit-box-align:stretch !important;-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-lg-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-lg-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-lg-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-lg-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-lg-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-lg-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-lg-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-lg-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-lg-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-lg-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-lg-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-lg-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 1200px){.flex-xl-row{-webkit-box-orient:horizontal !important;-webkit-box-direction:normal !important;-ms-flex-direction:row !important;flex-direction:row !important}.flex-xl-column{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.flex-xl-row-reverse{-webkit-box-orient:horizontal !important;-webkit-box-direction:reverse !important;-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-xl-column-reverse{-webkit-box-orient:vertical !important;-webkit-box-direction:reverse !important;-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-xl-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-xl-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-xl-fill{-webkit-box-flex:1 !important;-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-xl-grow-0{-webkit-box-flex:0 !important;-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-xl-grow-1{-webkit-box-flex:1 !important;-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-xl-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-xl-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-xl-start{-webkit-box-pack:start !important;-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-xl-end{-webkit-box-pack:end !important;-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-xl-center{-webkit-box-pack:center !important;-ms-flex-pack:center !important;justify-content:center !important}.justify-content-xl-between{-webkit-box-pack:justify !important;-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-xl-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-xl-start{-webkit-box-align:start !important;-ms-flex-align:start !important;align-items:flex-start !important}.align-items-xl-end{-webkit-box-align:end !important;-ms-flex-align:end !important;align-items:flex-end !important}.align-items-xl-center{-webkit-box-align:center !important;-ms-flex-align:center !important;align-items:center !important}.align-items-xl-baseline{-webkit-box-align:baseline !important;-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-xl-stretch{-webkit-box-align:stretch !important;-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-xl-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-xl-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-xl-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-xl-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-xl-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-xl-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-xl-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-xl-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-xl-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-xl-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-xl-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-xl-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}.float-left{float:left !important}.float-right{float:right !important}.float-none{float:none !important}@media (min-width: 576px){.float-sm-left{float:left !important}.float-sm-right{float:right !important}.float-sm-none{float:none !important}}@media (min-width: 768px){.float-md-left{float:left !important}.float-md-right{float:right !important}.float-md-none{float:none !important}}@media (min-width: 992px){.float-lg-left{float:left !important}.float-lg-right{float:right !important}.float-lg-none{float:none !important}}@media (min-width: 1200px){.float-xl-left{float:left !important}.float-xl-right{float:right !important}.float-xl-none{float:none !important}}.user-select-all{-webkit-user-select:all !important;-moz-user-select:all !important;-ms-user-select:all !important;user-select:all !important}.user-select-auto{-webkit-user-select:auto !important;-moz-user-select:auto !important;-ms-user-select:auto !important;user-select:auto !important}.user-select-none{-webkit-user-select:none !important;-moz-user-select:none !important;-ms-user-select:none !important;user-select:none !important}.overflow-auto{overflow:auto !important}.overflow-hidden{overflow:hidden !important}.position-static{position:static !important}.position-relative{position:relative !important}.position-absolute{position:absolute !important}.position-fixed{position:fixed !important}.position-sticky{position:-webkit-sticky !important;position:sticky !important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports (position: -webkit-sticky) or (position: sticky){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{-webkit-box-shadow:0 0.125rem 0.25rem rgba(0,0,0,0.075) !important;box-shadow:0 0.125rem 0.25rem rgba(0,0,0,0.075) !important}.shadow{-webkit-box-shadow:0 0.5rem 1rem rgba(0,0,0,0.15) !important;box-shadow:0 0.5rem 1rem rgba(0,0,0,0.15) !important}.shadow-lg{-webkit-box-shadow:0 1rem 3rem rgba(0,0,0,0.175) !important;box-shadow:0 1rem 3rem rgba(0,0,0,0.175) !important}.shadow-none{-webkit-box-shadow:none !important;box-shadow:none !important}.w-25{width:25% !important}.w-50{width:50% !important}.w-75{width:75% !important}.w-100{width:100% !important}.w-auto{width:auto !important}.h-25{height:25% !important}.h-50{height:50% !important}.h-75{height:75% !important}.h-100{height:100% !important}.h-auto{height:auto !important}.mw-100{max-width:100% !important}.mh-100{max-height:100% !important}.min-vw-100{min-width:100vw !important}.min-vh-100{min-height:100vh !important}.vw-100{width:100vw !important}.vh-100{height:100vh !important}.m-0{margin:0 !important}.mt-0,.my-0{margin-top:0 !important}.mr-0,.mx-0{margin-right:0 !important}.mb-0,.my-0{margin-bottom:0 !important}.ml-0,.mx-0{margin-left:0 !important}.m-1{margin:0.25rem !important}.mt-1,.my-1{margin-top:0.25rem !important}.mr-1,.mx-1{margin-right:0.25rem !important}.mb-1,.my-1{margin-bottom:0.25rem !important}.ml-1,.mx-1{margin-left:0.25rem !important}.m-2{margin:0.5rem !important}.mt-2,.my-2{margin-top:0.5rem !important}.mr-2,.mx-2{margin-right:0.5rem !important}.mb-2,.my-2{margin-bottom:0.5rem !important}.ml-2,.mx-2{margin-left:0.5rem !important}.m-3{margin:1rem !important}.mt-3,.my-3{margin-top:1rem !important}.mr-3,.mx-3{margin-right:1rem !important}.mb-3,.my-3{margin-bottom:1rem !important}.ml-3,.mx-3{margin-left:1rem !important}.m-4{margin:1.5rem !important}.mt-4,.my-4{margin-top:1.5rem !important}.mr-4,.mx-4{margin-right:1.5rem !important}.mb-4,.my-4{margin-bottom:1.5rem !important}.ml-4,.mx-4{margin-left:1.5rem !important}.m-5{margin:3rem !important}.mt-5,.my-5{margin-top:3rem !important}.mr-5,.mx-5{margin-right:3rem !important}.mb-5,.my-5{margin-bottom:3rem !important}.ml-5,.mx-5{margin-left:3rem !important}.p-0{padding:0 !important}.pt-0,.py-0{padding-top:0 !important}.pr-0,.px-0{padding-right:0 !important}.pb-0,.py-0{padding-bottom:0 !important}.pl-0,.px-0{padding-left:0 !important}.p-1{padding:0.25rem !important}.pt-1,.py-1{padding-top:0.25rem !important}.pr-1,.px-1{padding-right:0.25rem !important}.pb-1,.py-1{padding-bottom:0.25rem !important}.pl-1,.px-1{padding-left:0.25rem !important}.p-2{padding:0.5rem !important}.pt-2,.py-2{padding-top:0.5rem !important}.pr-2,.px-2{padding-right:0.5rem !important}.pb-2,.py-2{padding-bottom:0.5rem !important}.pl-2,.px-2{padding-left:0.5rem !important}.p-3{padding:1rem !important}.pt-3,.py-3{padding-top:1rem !important}.pr-3,.px-3{padding-right:1rem !important}.pb-3,.py-3{padding-bottom:1rem !important}.pl-3,.px-3{padding-left:1rem !important}.p-4{padding:1.5rem !important}.pt-4,.py-4{padding-top:1.5rem !important}.pr-4,.px-4{padding-right:1.5rem !important}.pb-4,.py-4{padding-bottom:1.5rem !important}.pl-4,.px-4{padding-left:1.5rem !important}.p-5{padding:3rem !important}.pt-5,.py-5{padding-top:3rem !important}.pr-5,.px-5{padding-right:3rem !important}.pb-5,.py-5{padding-bottom:3rem !important}.pl-5,.px-5{padding-left:3rem !important}.m-n1{margin:-0.25rem !important}.mt-n1,.my-n1{margin-top:-0.25rem !important}.mr-n1,.mx-n1{margin-right:-0.25rem !important}.mb-n1,.my-n1{margin-bottom:-0.25rem !important}.ml-n1,.mx-n1{margin-left:-0.25rem !important}.m-n2{margin:-0.5rem !important}.mt-n2,.my-n2{margin-top:-0.5rem !important}.mr-n2,.mx-n2{margin-right:-0.5rem !important}.mb-n2,.my-n2{margin-bottom:-0.5rem !important}.ml-n2,.mx-n2{margin-left:-0.5rem !important}.m-n3{margin:-1rem !important}.mt-n3,.my-n3{margin-top:-1rem !important}.mr-n3,.mx-n3{margin-right:-1rem !important}.mb-n3,.my-n3{margin-bottom:-1rem !important}.ml-n3,.mx-n3{margin-left:-1rem !important}.m-n4{margin:-1.5rem !important}.mt-n4,.my-n4{margin-top:-1.5rem !important}.mr-n4,.mx-n4{margin-right:-1.5rem !important}.mb-n4,.my-n4{margin-bottom:-1.5rem !important}.ml-n4,.mx-n4{margin-left:-1.5rem !important}.m-n5{margin:-3rem !important}.mt-n5,.my-n5{margin-top:-3rem !important}.mr-n5,.mx-n5{margin-right:-3rem !important}.mb-n5,.my-n5{margin-bottom:-3rem !important}.ml-n5,.mx-n5{margin-left:-3rem !important}.m-auto{margin:auto !important}.mt-auto,.my-auto{margin-top:auto !important}.mr-auto,.mx-auto{margin-right:auto !important}.mb-auto,.my-auto{margin-bottom:auto !important}.ml-auto,.mx-auto{margin-left:auto !important}@media (min-width: 576px){.m-sm-0{margin:0 !important}.mt-sm-0,.my-sm-0{margin-top:0 !important}.mr-sm-0,.mx-sm-0{margin-right:0 !important}.mb-sm-0,.my-sm-0{margin-bottom:0 !important}.ml-sm-0,.mx-sm-0{margin-left:0 !important}.m-sm-1{margin:0.25rem !important}.mt-sm-1,.my-sm-1{margin-top:0.25rem !important}.mr-sm-1,.mx-sm-1{margin-right:0.25rem !important}.mb-sm-1,.my-sm-1{margin-bottom:0.25rem !important}.ml-sm-1,.mx-sm-1{margin-left:0.25rem !important}.m-sm-2{margin:0.5rem !important}.mt-sm-2,.my-sm-2{margin-top:0.5rem !important}.mr-sm-2,.mx-sm-2{margin-right:0.5rem !important}.mb-sm-2,.my-sm-2{margin-bottom:0.5rem !important}.ml-sm-2,.mx-sm-2{margin-left:0.5rem !important}.m-sm-3{margin:1rem !important}.mt-sm-3,.my-sm-3{margin-top:1rem !important}.mr-sm-3,.mx-sm-3{margin-right:1rem !important}.mb-sm-3,.my-sm-3{margin-bottom:1rem !important}.ml-sm-3,.mx-sm-3{margin-left:1rem !important}.m-sm-4{margin:1.5rem !important}.mt-sm-4,.my-sm-4{margin-top:1.5rem !important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem !important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem !important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem !important}.m-sm-5{margin:3rem !important}.mt-sm-5,.my-sm-5{margin-top:3rem !important}.mr-sm-5,.mx-sm-5{margin-right:3rem !important}.mb-sm-5,.my-sm-5{margin-bottom:3rem !important}.ml-sm-5,.mx-sm-5{margin-left:3rem !important}.p-sm-0{padding:0 !important}.pt-sm-0,.py-sm-0{padding-top:0 !important}.pr-sm-0,.px-sm-0{padding-right:0 !important}.pb-sm-0,.py-sm-0{padding-bottom:0 !important}.pl-sm-0,.px-sm-0{padding-left:0 !important}.p-sm-1{padding:0.25rem !important}.pt-sm-1,.py-sm-1{padding-top:0.25rem !important}.pr-sm-1,.px-sm-1{padding-right:0.25rem !important}.pb-sm-1,.py-sm-1{padding-bottom:0.25rem !important}.pl-sm-1,.px-sm-1{padding-left:0.25rem !important}.p-sm-2{padding:0.5rem !important}.pt-sm-2,.py-sm-2{padding-top:0.5rem !important}.pr-sm-2,.px-sm-2{padding-right:0.5rem !important}.pb-sm-2,.py-sm-2{padding-bottom:0.5rem !important}.pl-sm-2,.px-sm-2{padding-left:0.5rem !important}.p-sm-3{padding:1rem !important}.pt-sm-3,.py-sm-3{padding-top:1rem !important}.pr-sm-3,.px-sm-3{padding-right:1rem !important}.pb-sm-3,.py-sm-3{padding-bottom:1rem !important}.pl-sm-3,.px-sm-3{padding-left:1rem !important}.p-sm-4{padding:1.5rem !important}.pt-sm-4,.py-sm-4{padding-top:1.5rem !important}.pr-sm-4,.px-sm-4{padding-right:1.5rem !important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem !important}.pl-sm-4,.px-sm-4{padding-left:1.5rem !important}.p-sm-5{padding:3rem !important}.pt-sm-5,.py-sm-5{padding-top:3rem !important}.pr-sm-5,.px-sm-5{padding-right:3rem !important}.pb-sm-5,.py-sm-5{padding-bottom:3rem !important}.pl-sm-5,.px-sm-5{padding-left:3rem !important}.m-sm-n1{margin:-0.25rem !important}.mt-sm-n1,.my-sm-n1{margin-top:-0.25rem !important}.mr-sm-n1,.mx-sm-n1{margin-right:-0.25rem !important}.mb-sm-n1,.my-sm-n1{margin-bottom:-0.25rem !important}.ml-sm-n1,.mx-sm-n1{margin-left:-0.25rem !important}.m-sm-n2{margin:-0.5rem !important}.mt-sm-n2,.my-sm-n2{margin-top:-0.5rem !important}.mr-sm-n2,.mx-sm-n2{margin-right:-0.5rem !important}.mb-sm-n2,.my-sm-n2{margin-bottom:-0.5rem !important}.ml-sm-n2,.mx-sm-n2{margin-left:-0.5rem !important}.m-sm-n3{margin:-1rem !important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem !important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem !important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem !important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem !important}.m-sm-n4{margin:-1.5rem !important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem !important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem !important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem !important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem !important}.m-sm-n5{margin:-3rem !important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem !important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem !important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem !important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem !important}.m-sm-auto{margin:auto !important}.mt-sm-auto,.my-sm-auto{margin-top:auto !important}.mr-sm-auto,.mx-sm-auto{margin-right:auto !important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto !important}.ml-sm-auto,.mx-sm-auto{margin-left:auto !important}}@media (min-width: 768px){.m-md-0{margin:0 !important}.mt-md-0,.my-md-0{margin-top:0 !important}.mr-md-0,.mx-md-0{margin-right:0 !important}.mb-md-0,.my-md-0{margin-bottom:0 !important}.ml-md-0,.mx-md-0{margin-left:0 !important}.m-md-1{margin:0.25rem !important}.mt-md-1,.my-md-1{margin-top:0.25rem !important}.mr-md-1,.mx-md-1{margin-right:0.25rem !important}.mb-md-1,.my-md-1{margin-bottom:0.25rem !important}.ml-md-1,.mx-md-1{margin-left:0.25rem !important}.m-md-2{margin:0.5rem !important}.mt-md-2,.my-md-2{margin-top:0.5rem !important}.mr-md-2,.mx-md-2{margin-right:0.5rem !important}.mb-md-2,.my-md-2{margin-bottom:0.5rem !important}.ml-md-2,.mx-md-2{margin-left:0.5rem !important}.m-md-3{margin:1rem !important}.mt-md-3,.my-md-3{margin-top:1rem !important}.mr-md-3,.mx-md-3{margin-right:1rem !important}.mb-md-3,.my-md-3{margin-bottom:1rem !important}.ml-md-3,.mx-md-3{margin-left:1rem !important}.m-md-4{margin:1.5rem !important}.mt-md-4,.my-md-4{margin-top:1.5rem !important}.mr-md-4,.mx-md-4{margin-right:1.5rem !important}.mb-md-4,.my-md-4{margin-bottom:1.5rem !important}.ml-md-4,.mx-md-4{margin-left:1.5rem !important}.m-md-5{margin:3rem !important}.mt-md-5,.my-md-5{margin-top:3rem !important}.mr-md-5,.mx-md-5{margin-right:3rem !important}.mb-md-5,.my-md-5{margin-bottom:3rem !important}.ml-md-5,.mx-md-5{margin-left:3rem !important}.p-md-0{padding:0 !important}.pt-md-0,.py-md-0{padding-top:0 !important}.pr-md-0,.px-md-0{padding-right:0 !important}.pb-md-0,.py-md-0{padding-bottom:0 !important}.pl-md-0,.px-md-0{padding-left:0 !important}.p-md-1{padding:0.25rem !important}.pt-md-1,.py-md-1{padding-top:0.25rem !important}.pr-md-1,.px-md-1{padding-right:0.25rem !important}.pb-md-1,.py-md-1{padding-bottom:0.25rem !important}.pl-md-1,.px-md-1{padding-left:0.25rem !important}.p-md-2{padding:0.5rem !important}.pt-md-2,.py-md-2{padding-top:0.5rem !important}.pr-md-2,.px-md-2{padding-right:0.5rem !important}.pb-md-2,.py-md-2{padding-bottom:0.5rem !important}.pl-md-2,.px-md-2{padding-left:0.5rem !important}.p-md-3{padding:1rem !important}.pt-md-3,.py-md-3{padding-top:1rem !important}.pr-md-3,.px-md-3{padding-right:1rem !important}.pb-md-3,.py-md-3{padding-bottom:1rem !important}.pl-md-3,.px-md-3{padding-left:1rem !important}.p-md-4{padding:1.5rem !important}.pt-md-4,.py-md-4{padding-top:1.5rem !important}.pr-md-4,.px-md-4{padding-right:1.5rem !important}.pb-md-4,.py-md-4{padding-bottom:1.5rem !important}.pl-md-4,.px-md-4{padding-left:1.5rem !important}.p-md-5{padding:3rem !important}.pt-md-5,.py-md-5{padding-top:3rem !important}.pr-md-5,.px-md-5{padding-right:3rem !important}.pb-md-5,.py-md-5{padding-bottom:3rem !important}.pl-md-5,.px-md-5{padding-left:3rem !important}.m-md-n1{margin:-0.25rem !important}.mt-md-n1,.my-md-n1{margin-top:-0.25rem !important}.mr-md-n1,.mx-md-n1{margin-right:-0.25rem !important}.mb-md-n1,.my-md-n1{margin-bottom:-0.25rem !important}.ml-md-n1,.mx-md-n1{margin-left:-0.25rem !important}.m-md-n2{margin:-0.5rem !important}.mt-md-n2,.my-md-n2{margin-top:-0.5rem !important}.mr-md-n2,.mx-md-n2{margin-right:-0.5rem !important}.mb-md-n2,.my-md-n2{margin-bottom:-0.5rem !important}.ml-md-n2,.mx-md-n2{margin-left:-0.5rem !important}.m-md-n3{margin:-1rem !important}.mt-md-n3,.my-md-n3{margin-top:-1rem !important}.mr-md-n3,.mx-md-n3{margin-right:-1rem !important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem !important}.ml-md-n3,.mx-md-n3{margin-left:-1rem !important}.m-md-n4{margin:-1.5rem !important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem !important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem !important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem !important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem !important}.m-md-n5{margin:-3rem !important}.mt-md-n5,.my-md-n5{margin-top:-3rem !important}.mr-md-n5,.mx-md-n5{margin-right:-3rem !important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem !important}.ml-md-n5,.mx-md-n5{margin-left:-3rem !important}.m-md-auto{margin:auto !important}.mt-md-auto,.my-md-auto{margin-top:auto !important}.mr-md-auto,.mx-md-auto{margin-right:auto !important}.mb-md-auto,.my-md-auto{margin-bottom:auto !important}.ml-md-auto,.mx-md-auto{margin-left:auto !important}}@media (min-width: 992px){.m-lg-0{margin:0 !important}.mt-lg-0,.my-lg-0{margin-top:0 !important}.mr-lg-0,.mx-lg-0{margin-right:0 !important}.mb-lg-0,.my-lg-0{margin-bottom:0 !important}.ml-lg-0,.mx-lg-0{margin-left:0 !important}.m-lg-1{margin:0.25rem !important}.mt-lg-1,.my-lg-1{margin-top:0.25rem !important}.mr-lg-1,.mx-lg-1{margin-right:0.25rem !important}.mb-lg-1,.my-lg-1{margin-bottom:0.25rem !important}.ml-lg-1,.mx-lg-1{margin-left:0.25rem !important}.m-lg-2{margin:0.5rem !important}.mt-lg-2,.my-lg-2{margin-top:0.5rem !important}.mr-lg-2,.mx-lg-2{margin-right:0.5rem !important}.mb-lg-2,.my-lg-2{margin-bottom:0.5rem !important}.ml-lg-2,.mx-lg-2{margin-left:0.5rem !important}.m-lg-3{margin:1rem !important}.mt-lg-3,.my-lg-3{margin-top:1rem !important}.mr-lg-3,.mx-lg-3{margin-right:1rem !important}.mb-lg-3,.my-lg-3{margin-bottom:1rem !important}.ml-lg-3,.mx-lg-3{margin-left:1rem !important}.m-lg-4{margin:1.5rem !important}.mt-lg-4,.my-lg-4{margin-top:1.5rem !important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem !important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem !important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem !important}.m-lg-5{margin:3rem !important}.mt-lg-5,.my-lg-5{margin-top:3rem !important}.mr-lg-5,.mx-lg-5{margin-right:3rem !important}.mb-lg-5,.my-lg-5{margin-bottom:3rem !important}.ml-lg-5,.mx-lg-5{margin-left:3rem !important}.p-lg-0{padding:0 !important}.pt-lg-0,.py-lg-0{padding-top:0 !important}.pr-lg-0,.px-lg-0{padding-right:0 !important}.pb-lg-0,.py-lg-0{padding-bottom:0 !important}.pl-lg-0,.px-lg-0{padding-left:0 !important}.p-lg-1{padding:0.25rem !important}.pt-lg-1,.py-lg-1{padding-top:0.25rem !important}.pr-lg-1,.px-lg-1{padding-right:0.25rem !important}.pb-lg-1,.py-lg-1{padding-bottom:0.25rem !important}.pl-lg-1,.px-lg-1{padding-left:0.25rem !important}.p-lg-2{padding:0.5rem !important}.pt-lg-2,.py-lg-2{padding-top:0.5rem !important}.pr-lg-2,.px-lg-2{padding-right:0.5rem !important}.pb-lg-2,.py-lg-2{padding-bottom:0.5rem !important}.pl-lg-2,.px-lg-2{padding-left:0.5rem !important}.p-lg-3{padding:1rem !important}.pt-lg-3,.py-lg-3{padding-top:1rem !important}.pr-lg-3,.px-lg-3{padding-right:1rem !important}.pb-lg-3,.py-lg-3{padding-bottom:1rem !important}.pl-lg-3,.px-lg-3{padding-left:1rem !important}.p-lg-4{padding:1.5rem !important}.pt-lg-4,.py-lg-4{padding-top:1.5rem !important}.pr-lg-4,.px-lg-4{padding-right:1.5rem !important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem !important}.pl-lg-4,.px-lg-4{padding-left:1.5rem !important}.p-lg-5{padding:3rem !important}.pt-lg-5,.py-lg-5{padding-top:3rem !important}.pr-lg-5,.px-lg-5{padding-right:3rem !important}.pb-lg-5,.py-lg-5{padding-bottom:3rem !important}.pl-lg-5,.px-lg-5{padding-left:3rem !important}.m-lg-n1{margin:-0.25rem !important}.mt-lg-n1,.my-lg-n1{margin-top:-0.25rem !important}.mr-lg-n1,.mx-lg-n1{margin-right:-0.25rem !important}.mb-lg-n1,.my-lg-n1{margin-bottom:-0.25rem !important}.ml-lg-n1,.mx-lg-n1{margin-left:-0.25rem !important}.m-lg-n2{margin:-0.5rem !important}.mt-lg-n2,.my-lg-n2{margin-top:-0.5rem !important}.mr-lg-n2,.mx-lg-n2{margin-right:-0.5rem !important}.mb-lg-n2,.my-lg-n2{margin-bottom:-0.5rem !important}.ml-lg-n2,.mx-lg-n2{margin-left:-0.5rem !important}.m-lg-n3{margin:-1rem !important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem !important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem !important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem !important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem !important}.m-lg-n4{margin:-1.5rem !important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem !important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem !important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem !important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem !important}.m-lg-n5{margin:-3rem !important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem !important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem !important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem !important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem !important}.m-lg-auto{margin:auto !important}.mt-lg-auto,.my-lg-auto{margin-top:auto !important}.mr-lg-auto,.mx-lg-auto{margin-right:auto !important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto !important}.ml-lg-auto,.mx-lg-auto{margin-left:auto !important}}@media (min-width: 1200px){.m-xl-0{margin:0 !important}.mt-xl-0,.my-xl-0{margin-top:0 !important}.mr-xl-0,.mx-xl-0{margin-right:0 !important}.mb-xl-0,.my-xl-0{margin-bottom:0 !important}.ml-xl-0,.mx-xl-0{margin-left:0 !important}.m-xl-1{margin:0.25rem !important}.mt-xl-1,.my-xl-1{margin-top:0.25rem !important}.mr-xl-1,.mx-xl-1{margin-right:0.25rem !important}.mb-xl-1,.my-xl-1{margin-bottom:0.25rem !important}.ml-xl-1,.mx-xl-1{margin-left:0.25rem !important}.m-xl-2{margin:0.5rem !important}.mt-xl-2,.my-xl-2{margin-top:0.5rem !important}.mr-xl-2,.mx-xl-2{margin-right:0.5rem !important}.mb-xl-2,.my-xl-2{margin-bottom:0.5rem !important}.ml-xl-2,.mx-xl-2{margin-left:0.5rem !important}.m-xl-3{margin:1rem !important}.mt-xl-3,.my-xl-3{margin-top:1rem !important}.mr-xl-3,.mx-xl-3{margin-right:1rem !important}.mb-xl-3,.my-xl-3{margin-bottom:1rem !important}.ml-xl-3,.mx-xl-3{margin-left:1rem !important}.m-xl-4{margin:1.5rem !important}.mt-xl-4,.my-xl-4{margin-top:1.5rem !important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem !important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem !important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem !important}.m-xl-5{margin:3rem !important}.mt-xl-5,.my-xl-5{margin-top:3rem !important}.mr-xl-5,.mx-xl-5{margin-right:3rem !important}.mb-xl-5,.my-xl-5{margin-bottom:3rem !important}.ml-xl-5,.mx-xl-5{margin-left:3rem !important}.p-xl-0{padding:0 !important}.pt-xl-0,.py-xl-0{padding-top:0 !important}.pr-xl-0,.px-xl-0{padding-right:0 !important}.pb-xl-0,.py-xl-0{padding-bottom:0 !important}.pl-xl-0,.px-xl-0{padding-left:0 !important}.p-xl-1{padding:0.25rem !important}.pt-xl-1,.py-xl-1{padding-top:0.25rem !important}.pr-xl-1,.px-xl-1{padding-right:0.25rem !important}.pb-xl-1,.py-xl-1{padding-bottom:0.25rem !important}.pl-xl-1,.px-xl-1{padding-left:0.25rem !important}.p-xl-2{padding:0.5rem !important}.pt-xl-2,.py-xl-2{padding-top:0.5rem !important}.pr-xl-2,.px-xl-2{padding-right:0.5rem !important}.pb-xl-2,.py-xl-2{padding-bottom:0.5rem !important}.pl-xl-2,.px-xl-2{padding-left:0.5rem !important}.p-xl-3{padding:1rem !important}.pt-xl-3,.py-xl-3{padding-top:1rem !important}.pr-xl-3,.px-xl-3{padding-right:1rem !important}.pb-xl-3,.py-xl-3{padding-bottom:1rem !important}.pl-xl-3,.px-xl-3{padding-left:1rem !important}.p-xl-4{padding:1.5rem !important}.pt-xl-4,.py-xl-4{padding-top:1.5rem !important}.pr-xl-4,.px-xl-4{padding-right:1.5rem !important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem !important}.pl-xl-4,.px-xl-4{padding-left:1.5rem !important}.p-xl-5{padding:3rem !important}.pt-xl-5,.py-xl-5{padding-top:3rem !important}.pr-xl-5,.px-xl-5{padding-right:3rem !important}.pb-xl-5,.py-xl-5{padding-bottom:3rem !important}.pl-xl-5,.px-xl-5{padding-left:3rem !important}.m-xl-n1{margin:-0.25rem !important}.mt-xl-n1,.my-xl-n1{margin-top:-0.25rem !important}.mr-xl-n1,.mx-xl-n1{margin-right:-0.25rem !important}.mb-xl-n1,.my-xl-n1{margin-bottom:-0.25rem !important}.ml-xl-n1,.mx-xl-n1{margin-left:-0.25rem !important}.m-xl-n2{margin:-0.5rem !important}.mt-xl-n2,.my-xl-n2{margin-top:-0.5rem !important}.mr-xl-n2,.mx-xl-n2{margin-right:-0.5rem !important}.mb-xl-n2,.my-xl-n2{margin-bottom:-0.5rem !important}.ml-xl-n2,.mx-xl-n2{margin-left:-0.5rem !important}.m-xl-n3{margin:-1rem !important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem !important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem !important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem !important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem !important}.m-xl-n4{margin:-1.5rem !important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem !important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem !important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem !important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem !important}.m-xl-n5{margin:-3rem !important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem !important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem !important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem !important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem !important}.m-xl-auto{margin:auto !important}.mt-xl-auto,.my-xl-auto{margin-top:auto !important}.mr-xl-auto,.mx-xl-auto{margin-right:auto !important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto !important}.ml-xl-auto,.mx-xl-auto{margin-left:auto !important}}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.text-monospace{font-family:SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important}.text-justify{text-align:justify !important}.text-wrap{white-space:normal !important}.text-nowrap{white-space:nowrap !important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left !important}.text-right{text-align:right !important}.text-center{text-align:center !important}@media (min-width: 576px){.text-sm-left{text-align:left !important}.text-sm-right{text-align:right !important}.text-sm-center{text-align:center !important}}@media (min-width: 768px){.text-md-left{text-align:left !important}.text-md-right{text-align:right !important}.text-md-center{text-align:center !important}}@media (min-width: 992px){.text-lg-left{text-align:left !important}.text-lg-right{text-align:right !important}.text-lg-center{text-align:center !important}}@media (min-width: 1200px){.text-xl-left{text-align:left !important}.text-xl-right{text-align:right !important}.text-xl-center{text-align:center !important}}.text-lowercase{text-transform:lowercase !important}.text-uppercase{text-transform:uppercase !important}.text-capitalize{text-transform:capitalize !important}.font-weight-light{font-weight:300 !important}.font-weight-lighter{font-weight:lighter !important}.font-weight-normal{font-weight:400 !important}.font-weight-bold{font-weight:700 !important}.font-weight-bolder{font-weight:bolder !important}.font-italic{font-style:italic !important}.text-white{color:#fff !important}.text-primary{color:#375a7f !important}a.text-primary:hover,a.text-primary:focus{color:#20344a !important}.text-secondary{color:#444 !important}a.text-secondary:hover,a.text-secondary:focus{color:#1e1e1e !important}.text-success{color:#00bc8c !important}a.text-success:hover,a.text-success:focus{color:#007053 !important}.text-info{color:#3498DB !important}a.text-info:hover,a.text-info:focus{color:#1d6fa5 !important}.text-warning{color:#F39C12 !important}a.text-warning:hover,a.text-warning:focus{color:#b06f09 !important}.text-danger{color:#E74C3C !important}a.text-danger:hover,a.text-danger:focus{color:#bf2718 !important}.text-light{color:#adb5bd !important}a.text-light:hover,a.text-light:focus{color:#838f9b !important}.text-dark{color:#303030 !important}a.text-dark:hover,a.text-dark:focus{color:#0a0a0a !important}.text-body{color:#fff !important}.text-muted{color:#888 !important}.text-black-50{color:rgba(0,0,0,0.5) !important}.text-white-50{color:rgba(255,255,255,0.5) !important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none !important}.text-break{word-wrap:break-word !important}.text-reset{color:inherit !important}.visible{visibility:visible !important}.invisible{visibility:hidden !important}@media print{*,*::before,*::after{text-shadow:none !important;-webkit-box-shadow:none !important;box-shadow:none !important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap !important}pre,blockquote{border:1px solid #adb5bd;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px !important}.container{min-width:992px !important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse !important}.table td,.table th{background-color:#fff !important}.table-bordered th,.table-bordered td{border:1px solid #dee2e6 !important}.table-dark{color:inherit}.table-dark th,.table-dark td,.table-dark thead th,.table-dark tbody+tbody{border-color:#444}.table .thead-dark th{color:inherit;border-color:#444}}.blockquote-footer{color:#888}.table-primary,.table-primary>th,.table-primary>td{background-color:#375a7f}.table-secondary,.table-secondary>th,.table-secondary>td{background-color:#444}.table-light,.table-light>th,.table-light>td{background-color:#adb5bd}.table-dark,.table-dark>th,.table-dark>td{background-color:#303030}.table-success,.table-success>th,.table-success>td{background-color:#00bc8c}.table-info,.table-info>th,.table-info>td{background-color:#3498DB}.table-danger,.table-danger>th,.table-danger>td{background-color:#E74C3C}.table-warning,.table-warning>th,.table-warning>td{background-color:#F39C12}.table-active,.table-active>th,.table-active>td{background-color:rgba(0,0,0,0.075)}.table-hover .table-primary:hover,.table-hover .table-primary:hover>th,.table-hover .table-primary:hover>td{background-color:#2f4d6d}.table-hover .table-secondary:hover,.table-hover .table-secondary:hover>th,.table-hover .table-secondary:hover>td{background-color:#373737}.table-hover .table-light:hover,.table-hover .table-light:hover>th,.table-hover .table-light:hover>td{background-color:#9fa8b2}.table-hover .table-dark:hover,.table-hover .table-dark:hover>th,.table-hover .table-dark:hover>td{background-color:#232323}.table-hover .table-success:hover,.table-hover .table-success:hover>th,.table-hover .table-success:hover>td{background-color:#00a379}.table-hover .table-info:hover,.table-hover .table-info:hover>th,.table-hover .table-info:hover>td{background-color:#258cd1}.table-hover .table-danger:hover,.table-hover .table-danger:hover>th,.table-hover .table-danger:hover>td{background-color:#e43725}.table-hover .table-warning:hover,.table-hover .table-warning:hover>th,.table-hover .table-warning:hover>td{background-color:#e08e0b}.table-hover .table-active:hover,.table-hover .table-active:hover>th,.table-hover .table-active:hover>td{background-color:rgba(0,0,0,0.075)}.input-group-addon{color:#fff}.nav-tabs .nav-link,.nav-tabs .nav-link.active,.nav-tabs .nav-link.active:focus,.nav-tabs .nav-link.active:hover,.nav-tabs .nav-item.open .nav-link,.nav-tabs .nav-item.open .nav-link:focus,.nav-tabs .nav-item.open .nav-link:hover,.nav-pills .nav-link,.nav-pills .nav-link.active,.nav-pills .nav-link.active:focus,.nav-pills .nav-link.active:hover,.nav-pills .nav-item.open .nav-link,.nav-pills .nav-item.open .nav-link:focus,.nav-pills .nav-item.open .nav-link:hover{color:#fff}.breadcrumb a{color:#fff}.pagination a:hover{text-decoration:none}.close{opacity:0.4}.close:hover,.close:focus{opacity:1}.alert{border:none;color:#fff}.alert a,.alert .alert-link{color:#fff;text-decoration:underline}.alert-primary{background-color:#375a7f}.alert-secondary{background-color:#444}.alert-success{background-color:#00bc8c}.alert-info{background-color:#3498DB}.alert-warning{background-color:#F39C12}.alert-danger{background-color:#E74C3C}.alert-light{background-color:#adb5bd}.alert-dark{background-color:#303030}.list-group-item-action{color:#fff}.list-group-item-action:hover,.list-group-item-action:focus{background-color:#444;color:#fff}.list-group-item-action .list-group-item-heading{color:#fff} diff --git a/openwebrx/htdocs/css/login.css b/openwebrx/htdocs/css/login.css new file mode 100644 index 0000000..ccd6c02 --- /dev/null +++ b/openwebrx/htdocs/css/login.css @@ -0,0 +1,34 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +body { + display: flex; + flex-direction: column; +} + +.login-container { + flex: 1; + position: relative; +} + +.login { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + + width: 500px; + + padding: 20px; + border-radius: 10px; + border: 1px solid #575757; + box-shadow: 0 0 20px #000; +} + +.login .btn { + width: 100%; +} + +.btn-login { + height: 50px; +} \ No newline at end of file diff --git a/openwebrx/htdocs/css/map.css b/openwebrx/htdocs/css/map.css new file mode 100644 index 0000000..70702b9 --- /dev/null +++ b/openwebrx/htdocs/css/map.css @@ -0,0 +1,65 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +body { + display: flex; + flex-direction: column; +} + +.openwebrx-map { + flex: 1 1 auto; +} + +h3 { + margin: 10px 0; + text-align: center; +} + +ul { + margin-block-start: 5px; + margin-block-end: 5px; + padding-inline-start: 25px; +} + +/* don't show the filter in it's initial position */ +.openwebrx-map-legend { + display: none; + background-color: #fff; + padding: 10px; + margin: 10px; + user-select: none; +} + +/* show it as soon as google maps has moved it to its container */ +.openwebrx-map .openwebrx-map-legend { + display: block; +} + +.openwebrx-map-legend ul { + list-style-type: none; + padding: 0; +} + +.openwebrx-map-legend ul li { + cursor: pointer; +} + +.openwebrx-map-legend ul li.disabled { + opacity: .3; + filter: grayscale(70%); +} + +.openwebrx-map-legend li.square .illustration { + display: inline-block; + width: 30px; + height: 20px; + margin-right: 10px; + border-width: 2px; + border-style: solid; +} + +.openwebrx-map-legend select { + background-color: #FFF; + border-color: #DDD; + padding: 5px; +} diff --git a/openwebrx/htdocs/css/openwebrx-globals.css b/openwebrx/htdocs/css/openwebrx-globals.css new file mode 100644 index 0000000..5759847 --- /dev/null +++ b/openwebrx/htdocs/css/openwebrx-globals.css @@ -0,0 +1,7 @@ +html, body +{ + margin: 0; + padding: 0; + height: 100%; + font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; +} diff --git a/openwebrx/htdocs/css/openwebrx-header.css b/openwebrx/htdocs/css/openwebrx-header.css new file mode 100644 index 0000000..4e8601b --- /dev/null +++ b/openwebrx/htdocs/css/openwebrx-header.css @@ -0,0 +1,227 @@ +.webrx-top-container { + position: relative; + z-index:1000; + background-color: #575757; + + background-image: url(../gfx/openwebrx-top-photo.jpg); + background-position-x: center; + background-position-y: top; + background-repeat: no-repeat; + background-size: cover; + + overflow: hidden; +} + +.openwebrx-description-container { + transition-property: height, opacity; + transition-duration: 1s; + transition-timing-function: ease-out; + opacity: 0; + height: 0; + /* originally, top-bar + description was 350px */ + max-height: 283px; + overflow: hidden; +} + +.openwebrx-description-container.expanded { + opacity: 1; + height: 283px; +} + +.webrx-top-bar { + height:67px; + + background: rgba(128, 128, 128, 0.15); + margin:0; + padding:0; + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + overflow: hidden; + + display: flex; + flex-direction: row; +} + +.webrx-top-bar > * { + flex: 0; +} + +.webrx-top-container, .webrx-top-container * { + line-height: initial; + box-sizing: initial; +} + +.webrx-top-logo { + width: 261px; + padding: 12px; + filter: drop-shadow(0 0 2.5px rgba(0, 0, 0, .9)); + /* overwritten by media queries */ + display: none; +} + +.webrx-rx-avatar { + background-color: rgba(154, 154, 154, .5); + margin: 7px; + + width: 46px; + height: 46px; + padding: 4px; + border-radius: 8px; + box-sizing: content-box; +} + +.webrx-rx-texts { + /* minimum layout width */ + width: 0; + /* will be getting wider with flex */ + flex: 1; + overflow: hidden; + margin: auto 0; +} + +.webrx-rx-texts div, .webrx-rx-texts h1 { + margin: 0 10px; + padding: 3px; + white-space:nowrap; + overflow: hidden; + color: #909090; + text-align: left; +} + +.webrx-rx-title { + font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; + font-size: 11pt; + font-weight: bold; +} + +.webrx-rx-desc { + font-size: 10pt; +} + +.openwebrx-main-buttons .button { + display: block; + width: 55px; + cursor:pointer; +} + +.openwebrx-main-buttons .button[data-toggle-panel] { + /* will be enabled by javascript if the panel is present in the DOM */ + display: none; +} + +.openwebrx-main-buttons .button img, +.openwebrx-main-buttons .button svg { + height: 38px; + filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.5)); +} + +.openwebrx-main-buttons a { + color: inherit; + text-decoration: inherit; +} + +.openwebrx-main-buttons .button:hover { + background-color: rgba(255, 255, 255, 0.3); +} + +.openwebrx-main-buttons .button:active { + background-color: rgba(255, 255, 255, 0.55); +} + + +.openwebrx-main-buttons { + padding: 5px 15px; + display: flex; + list-style: none; + margin:0; + color: white; + text-shadow: 0px 0px 4px #000000; + text-align: center; + font-size: 9pt; + font-weight: bold; +} + +.webrx-rx-photo-title { + margin: 10px 15px; + color: white; + font-size: 16pt; + text-shadow: 1px 1px 4px #444; + opacity: 1; +} + +.webrx-rx-photo-desc { + margin: 10px 15px; + color: white; + font-size: 10pt; + font-weight: bold; + text-shadow: 0px 0px 6px #444; + opacity: 1; + line-height: 1.5em; +} + +.webrx-rx-photo-desc a { + color: #5ca8ff; + text-shadow: none; +} + +.openwebrx-photo-trigger { + cursor: pointer; +} + +/* + * Responsive stuff + */ + +@media (min-width: 576px) { + .webrx-rx-texts { + display: initial; + } +} + +@media (min-width: 768px) { +} + +@media (min-width: 992px) { + .webrx-top-logo { + display: initial; + } +} + +@media (min-width: 1200px) { +} + +/* + * RX details arrow up/down switching + */ + +.openwebrx-rx-details-arrow { + position: absolute; + bottom: 0; + left: 50%; + transform: translate(-50%, 0); + + margin: 0; + padding: 0; + line-height: 0; + display: block; +} + +.openwebrx-rx-details-arrow svg { + height: 12px; +} + +.openwebrx-rx-details-arrow .up { + display: none; +} + +.openwebrx-rx-details-arrow--up .down { + display: none; +} + +.openwebrx-rx-details-arrow--up .up { + display: initial; +} \ No newline at end of file diff --git a/openwebrx/htdocs/css/openwebrx.css b/openwebrx/htdocs/css/openwebrx.css new file mode 100644 index 0000000..cb1e954 --- /dev/null +++ b/openwebrx/htdocs/css/openwebrx.css @@ -0,0 +1,1364 @@ +/* + + This file is part of OpenWebRX, + an open-source SDR receiver software with a web UI. + Copyright (c) 2013-2015 by Andras Retzler + Copyright (c) 2019-2021 by Jakob Ketterl + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +*/ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +html, body { + overflow: hidden; +} + +select +{ + font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; +} + +input +{ + vertical-align:middle; +} + +input[type=range] { + -webkit-appearance: none; + margin: 0 0; + background: transparent !important; + --track-background: #B6B6B6; +} + +input[type=range]:focus { + outline: none; +} + +input[type=range]::-webkit-slider-runnable-track +{ + height: 5px; + cursor: pointer; + animate: 0.2s; + box-shadow: 0px 0px 0px #000000; + background: #B6B6B6; + /*border-radius: 11px;*/ + border: 1px solid #8A8A8A; + background: var(--track-background); +} + +input[type=range]::-webkit-slider-thumb +{ + box-shadow: 1px 1px 1px #828282; + border: 1px solid #8A8A8A; + height: 15px; + width: 15px; + border-radius: 10px; + background: #FFFFFF; + cursor: pointer; + -webkit-appearance: none; + margin-top: -7px; +} + +input[type=range]:focus::-webkit-slider-runnable-track +{ + background: #B6B6B6; + background: var(--track-background); +} + +input[type=range]::-moz-range-track +{ + height: 3px; + cursor: pointer; + animate: 0.2s; + box-shadow: 0px 0px 0px #000000; + background: #B6B6B6; + background: var(--track-background); + border-radius: 11px; + border: 1px solid #8A8A8A; +} + +input[type=range]::-moz-range-thumb +{ + box-shadow: 1px 1px 1px #828282; + border: 1px solid #8A8A8A; + height: 12px; + width: 12px; + border-radius: 10px; + background: #FFFFFF; + cursor: pointer; +} + +input[type=range]::-ms-track +{ + width: 100%; + height: 7px; + cursor: pointer; + animate: 0.2s; + background: transparent; + border-color: transparent; + color: transparent; +} + +input[type=range]::-ms-fill-lower + { + background: #B6B6B6; + border: 1px solid #8A8A8A; + border-radius: 22px; + box-shadow: 0px 0px 0px #000000; +} + +input[type=range]::-ms-fill-upper +{ + background: #B6B6B6; + border: 1px solid #8A8A8A; + border-radius: 22px; + box-shadow: 0px 0px 0px #000000; +} + +input[type=range]::-ms-thumb +{ + box-shadow: 1px 1px 1px #828282; + border: 1px solid #8A8A8A; + height: 24px; + width: 7px; + border-radius: 0px; + background: #FFFFFF; + cursor: pointer; +} + +input[type=range]:focus::-ms-fill-lower +{ + background: #B6B6B6; +} + +input[type=range]:focus::-ms-fill-upper +{ + background: #B6B6B6; +} + +input[type=range]:disabled { + opacity: 0.5; +} + +#webrx-page-container +{ + height: 100%; + position: relative; + display: flex; + flex-direction: column; +} + +#openwebrx-scale-container +{ + height: 47px; + overflow: hidden; + z-index:1000; + position: relative; +} + +#openwebrx-frequency-container { + background-image: url("../gfx/openwebrx-scale-background.png"); + background-repeat: repeat-x; + background-size: cover; + background-color: #444; + z-index: 1001; +} + +#openwebrx-bookmarks-container +{ + height: 25px; + position: relative; + z-index: 1000; +} + +#openwebrx-bookmarks-container .bookmark { + font-size: 12px; + background-color: #FFFF00; + border: 1px solid #000; + border-radius: 5px; + padding: 2px 5px; + cursor: pointer; + white-space: nowrap; + max-height: 14px; + max-width: 50px; + + position: absolute; + bottom: 5px; + transform: translate(-50%, 0); +} + +#openwebrx-bookmarks-container .bookmark .bookmark-content { + overflow: hidden; + text-overflow: ellipsis; +} + +#openwebrx-bookmarks-container .bookmark .bookmark-actions { + display: none; + text-align: right; +} + +.bookmark-actions .action { + line-height: 0; +} + +.bookmark-actions .action img { + width: 14px; +} + +#openwebrx-bookmarks-container .bookmark.selected { + z-index: 1010; +} + +#openwebrx-bookmarks-container .bookmark:hover { + z-index: 1011; + max-height: none; + max-width: none; +} + +#openwebrx-bookmarks-container .bookmark[editable]:hover .bookmark-actions { + display: block; + margin-bottom: 5px; +} + +#openwebrx-bookmarks-container .bookmark:after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + width: 0; + height: 0; + border: 5px solid transparent; + border-top-color: #FFFF00; + border-bottom: 0; + margin-left: -5px; + margin-bottom: -5px; +} + +#openwebrx-bookmarks-container .bookmark[data-source=local] { + background-color: #0FF; +} + +#openwebrx-bookmarks-container .bookmark[data-source=local]:after { + border-top-color: #0FF; +} + +#openwebrx-bookmarks-container .bookmark[data-source=dial_frequencies] { + background-color: #0F0; +} + +#openwebrx-bookmarks-container .bookmark[data-source=dial_frequencies]:after { + border-top-color: #0F0; +} + +#webrx-canvas-background { + flex-grow: 1; + background-image: url('../gfx/openwebrx-background-cool-blue.png'); + background-repeat: no-repeat; + background-color: #1e5f7f; + background-size: cover; + display: flex; + flex-direction: column; +} + +@supports(background-image: -webkit-image-set(url('../gfx/openwebrx-background-cool-blue.webp') 1x)) { + #webrx-canvas-background { + background-image: -webkit-image-set(url('../gfx/openwebrx-background-cool-blue.webp') 1x); + } +} + +@supports(background-image: image-set(url('../gfx/openwebrx-background-cool-blue.webp') 1x)) { + #webrx-canvas-background { + background-image: image-set(url('../gfx/openwebrx-background-cool-blue.webp') 1x); + } +} + +#webrx-canvas-container +{ + position: relative; + overflow: visible; + cursor: crosshair; + flex-grow: 1; +} + +#webrx-canvas-container canvas +{ + position: absolute; + top: 0; + border-style: none; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + width: 100%; + height: 200px; + will-change: transform; +} + +#openwebrx-log-scroll +{ + /*overflow-y:auto;*/ + height: 125px; + width: 619px +} + +.nano .nano-pane { background: #444; } +.nano .nano-slider { background: #eee !important; } + +.webrx-error +{ + font-weight: bold; + color: #ff6262; +} + +@font-face { + font-family: 'roboto-mono'; + src: url('../fonts/RobotoMono-Regular.woff2') format('woff2'), + url('../fonts/RobotoMono-Regular.woff') format('woff'), + url('../fonts/RobotoMono-Regular.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +.webrx-actual-freq { + width: 100%; + text-align: left; + padding: 0; + margin: 0; + display: flex; + flex-direction: row; + cursor: pointer; +} + +.webrx-actual-freq > * { + flex: 1; +} + +.webrx-actual-freq .input-group { + display: flex; + flex-direction: row; +} + +.webrx-actual-freq .input-group > * { + flex: 0 0 auto; +} + +.webrx-actual-freq .input-group input { + flex: 1 0 auto; + margin-right: 0; + border-right: 1px solid #373737; + -moz-appearance: textfield; +} + +.webrx-actual-freq .input-group input::-webkit-outer-spin-button, +.webrx-actual-freq .input-group input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.input-group > :not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group > :not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.input-group :first-child { + padding-left: 5px; +} + +.input-group :last-child { + padding-right: 5px +} + +.webrx-actual-freq .input-group input, .webrx-actual-freq .input-group select { + outline: none; + font-size: 16pt; +} + +.webrx-actual-freq input { + font-family: 'roboto-mono'; + width: 0; + box-sizing: border-box; + border: 0; + padding: 0; + background-color: inherit; + color: inherit; +} + +.webrx-actual-freq, .webrx-actual-freq input { + font-size: 16pt; + font-family: 'roboto-mono'; +} + +.webrx-actual-freq .digit { + cursor: ns-resize; +} + +.webrx-actual-freq .digit:hover { + color: #FFFF50; + border-radius: 5px; + background: -webkit-gradient( linear, left top, left bottom, color-stop(0.0 , #373737), color-stop(1, #4F4F4F) ); + background: -moz-linear-gradient( center top, #373737 0%, #4F4F4F 100% ); +} + +.webrx-mouse-freq { + width: 100%; + text-align: left; + font-size: 10pt; + color: #AAA; + font-family: 'roboto-mono'; + margin-bottom: 5px; +} + +#openwebrx-panels-container-left, +#openwebrx-panels-container-right { + position: absolute; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + height: 0; + overflow: visible; +} + +#openwebrx-panels-container-left { + left: 0; + align-items: flex-start; +} + +#openwebrx-panels-container-right { + right: 0; + align-items: flex-end; +} + +.openwebrx-panel +{ + transform: perspective( 600px ) rotateX( 90deg ); + background-color: #575757; + padding: 10px; + color: white; + font-size: 10pt; + border-radius: 15px; + -moz-border-radius: 15px; + margin: 5.9px; + box-sizing: content-box; +} + +.openwebrx-panel a +{ + color: #5ca8ff; + text-shadow: none; +} + +.openwebrx-panel-inner +{ + overflow-y: auto; + overflow-x: hidden; + height: 100%; +} + +.openwebrx-button +{ + background-color: #373737; + padding: 4.2px; + border-radius: 5px; + -moz-border-radius: 5px; + color: White; + font-weight: bold; + margin-right: 1px; + cursor: pointer; + background:-webkit-gradient( linear, left top, left bottom, color-stop(0.0 , #373737), color-stop(1, #4F4F4F) ); + background:-moz-linear-gradient( center top, #373737 0%, #4F4F4F 100% ); + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + display: inline-block; +} + +.openwebrx-button:hover, .openwebrx-demodulator-button.highlighted, .openwebrx-button.highlighted +{ + /*background:-webkit-gradient( linear, left top, left bottom, color-stop(0.0 , #3F3F3F), color-stop(1, #777777) ); + background:-moz-linear-gradient( center top, #373737 5%, #4F4F4F 100% );*/ + background: #474747; + color: #FFFF50; +} + +.openwebrx-button:active +{ + background: #777777; + color: #FFFF50; +} + +.openwebrx-button:last-child { + margin-right: 0; +} + +.openwebrx-button.disabled { + opacity: 0.5; +} + +.openwebrx-demodulator-button +{ + height: 19px; + font-size: 12pt; + text-align: center; + flex: 1; + margin-right: 5px; +} + +.openwebrx-demodulator-button.same-mod { + color: #FFC; +} + +.openwebrx-square-button img +{ + height: 27px; +} + +.openwebrx-round-button +{ + margin-right: -2px; + width: 35px; + height: 35px; + border-radius: 25px; +} + +.openwebrx-round-button img +{ + height: 30px; +} + +.openwebrx-round-button-small +{ + margin-right: -3px; + width: 20px; + height: 20px; + border-radius: 25px; +} + +.openwebrx-round-button-small img +{ + height: 20px; +} + +img.openwebrx-mirror-img +{ + transform: scale(-1, 1); +} + + +.openwebrx-round-rightarrow img +{ + position: relative; + left: 12px; + top: 3px; +} + +.openwebrx-round-leftarrow img +{ + position: relative; + left: 7px; + top: 3px; +} + +#openwebrx-client-log-title +{ + margin-bottom: 5px; + font-weight: bold; +} + +.openwebrx-progressbar +{ + position: relative; + border-radius: 5px; + background-color: #003850; /*#006235;*/ + display: inline-block; + text-align: center; + font-size: 8pt; + font-weight: bold; + text-shadow: 0px 0px 4px #000000; + cursor: default; + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + overflow: hidden; + z-index: 1 +} + +.openwebrx-progressbar-bar { + background-color: #00aba6; + border-radius: 5px; + height: 100%; + width: 100%; + transition-property: transform, background-color; + transition-duration: 1s; + transition-timing-function: ease-in-out; + transform: translate(-100%) translateZ(0); + will-change: transform, background-color; + z-index: 0; +} + +.openwebrx-progressbar--over .openwebrx-progressbar-bar { + background-color: #ff6262; +} + +.openwebrx-progressbar-text +{ + position: absolute; + left:50%; + top:50%; + transform: translate(-50%, -50%); + white-space: nowrap; + z-index: 2; +} + +#openwebrx-panel-status +{ + margin: 0 0 0 5.9px; + padding: 0px; + background-color:rgba(0, 0, 0, 0); +} + +#openwebrx-panel-status div.openwebrx-progressbar +{ + width: 200px; + height: 20px; +} + +#openwebrx-panel-receiver +{ + width:110px; +} + + +#openwebrx-panel-receiver .frequencies-container { + display: flex; + flex-direction: row; + gap: 5px; +} + +#openwebrx-panel-receiver .frequencies { + flex-grow: 1; +} + +#openwebrx-panel-receiver .openwebrx-bookmark-button { + width: 27px; + height: 27px; + text-align: center; +} + +.openwebrx-panel-slider +{ + position: relative; + top: -2px; + width: 95px; +} + +.openwebrx-panel-line +{ + padding-top: 5px; +} + +.openwebrx-panel-flex-line { + display: flex; + flex-direction: row; +} + +.openwebrx-panel-line:first-child { + padding-top: 0; +} + +.openwebrx-modes-grid { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: -5px -5px 0 0; +} + +.openwebrx-modes-grid .openwebrx-demodulator-button { + margin: 0; + white-space: nowrap; + flex: 1 0 38px; + margin: 5px 5px 0 0; +} + +@supports(gap: 5px) { + .openwebrx-modes-grid { + margin: 0; + gap: 5px; + } + + .openwebrx-modes-grid .openwebrx-demodulator-button { + margin: 0; + } +} + +#openwebrx-smeter { + border-color: #888; + border-style: solid; + border-width: 0px; + width: 255px; + height: 7px; + background-color: #373737; + border-radius: 3px; + overflow: hidden; +} + +.openwebrx-smeter-bar { + transition-property: transform; + transition-duration: 0.2s; + transition-timing-function: linear; + will-change: transform; + transform: translate(-100%) translateZ(0); + width: 100%; + height: 100%; + background: linear-gradient(to top, #ff5939 , #961700); + margin: 0; + padding: 0; + border-radius: 3px; +} + +#openwebrx-smeter-db +{ + color: #aaa; + display: inline-block; + font-size: 10pt; + float: right; + margin-right: 5px; + margin-top: 24px; + font-family: 'roboto-mono'; +} + +.openwebrx-overlay { + position: absolute; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + opacity: 0.8; + background-color: #777; + left: 0; + top: 0; + z-index: 1001; + color: white; + font-weight: bold; + font-size: 20pt; +} + +#openwebrx-autoplay-overlay +{ + cursor: pointer; + transition: opacity 0.3s linear; +} + +#openwebrx-autoplay-overlay svg { + width: 150px; +} + +.openwebrx-overlay .overlay-content { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; +} + +#openwebrx-error-overlay .overlay-content { + background-color: #000; + padding: 50px; + border-radius: 20px; +} + +#openwebrx-digimode-canvas-container +{ + /*margin: -10px -10px 10px -10px;*/ + margin: -10px -10px 0px -10px; + border-radius: 15px; + height: 150px; + background-color: #333; + position: relative; + overflow: hidden; +} + +#openwebrx-digimode-canvas-container canvas +{ + position: absolute; + top: 0; + pointer-events: none; + transition: width 500ms, left 500ms; + will-change: transform; +} + +.openwebrx-panel select, +.openwebrx-panel input, +.openwebrx-dialog select, +.openwebrx-dialog input { + border-radius: 5px; + background-color: #373737; + color: White; + font-weight: normal; + font-size: 13pt; + margin-right: 1px; + background:linear-gradient(#373737, #4F4F4F); + border-color: transparent; + border-width: 0px; +} + +@supports(-moz-appearance: none) { + .openwebrx-panel select, + .openwebrx-dialog select { + -moz-appearance: none; + background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%20%20xmlns%3Av%3D%22https%3A%2F%2Fvecta.io%2Fnano%22%3E%3Cpath%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8s-1.9-9.2-5.5-12.8z%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E'), + linear-gradient(#373737, #4F4F4F); + background-repeat: no-repeat, repeat; + background-position: right .3em top 50%, 0 0; + background-size: .65em auto, 100%; + } + + .openwebrx-panel .input-group select, + .openwebrx-dialog .input-group select { + padding-right: 1em; + } +} + +.openwebrx-panel select option, +.openwebrx-dialog select option { + border-width: 0px; + background-color: #373737; + color: White; +} + +.openwebrx-secondary-demod-listbox { + width: 173px; + height: 27px; + padding-left:3px; + flex: 4; +} + +#openwebrx-sdr-profiles-listbox { + width: 100%; + font-size: 10pt; + height: 27px; +} + +#openwebrx-cursor-blink +{ + animation: cursor-blink 1s infinite; + /*animation: cursor-3d 2s infinite;*/ + animation-timing-function: linear; + animation-direction: alternate; + height: 1em; + width: 8px; + background-color: White; + display: inline-block; + position: relative; + top: 1px; + /*perspective: 60px;*/ + +} + +@keyframes cursor-blink +{ + 0%{ opacity: 0; } + 50% { opacity: 1; } + 100%{ opacity: 0; } +} + +@keyframes cursor-3d +{ + 0%{ transform: rotateX(0deg) rotateX(Ydeg); } + 50% { transform: rotateX(180deg) rotateY(360deg); opacity: 0.1; } + 100%{ transform: rotateX(360deg) rotateY(720deg); } +} + +#openwebrx-digimode-content +{ + word-wrap: break-word; + position: absolute; + bottom: 0; + width: 100%; +} + +#openwebrx-digimode-content-container +{ + overflow-y: hidden; + display: block; + height: 50px; + position: relative; +} + +#openwebrx-digimode-content-container .gradient +{ + width: 100%; + height: 20px; + background: linear-gradient(to top, rgba(87,87,87,0) 0%,rgba(87,87,87,1) 100%); + position: absolute; + top: 0; + z-index: 10; +} + + +#openwebrx-digimode-content .part +{ + perspective: 700px; +} + +#openwebrx-digimode-content .part +{ + animation: new-digimode-data-3d 100ms; + animation-timing-function: linear; + display: inline-block; + perspective-origin: 50% 50%; + transform-origin: 0% 50%; +} + +@keyframes new-digimode-data +{ + 0%{ opacity: 0; } + 100%{ opacity: 1; } +} + +@keyframes new-digimode-data-3d +{ + 0%{ transform: rotateX(0deg) rotateY(-90deg) translateX(-5px) scale(1.3); } + 100%{ transform: rotateX(0deg) rotateY(0deg) translateX(0) scale(1); } +} + +#openwebrx-digimode-select-channel +{ + transition: all 500ms; + background-color: Yellow; + display: block; + position: absolute; + pointer-events: none; + height: 100%; + width: 0px; + top: 0px; + left: 0px; + opacity: 0.7; + border-style: solid; + border-width: 0px; + border-color: Red; +} + +.openwebrx-meta-panel { + display: flex; + flex-direction: row; + gap: 10px; + /* compatibility with iOS 14.2 */ + flex: 0 0 auto; +} + +.openwebrx-meta-slot { + flex: 1; + width: 145px; + height: 196px; + + background-color: #676767; + padding: 2px 0; + color: #333; + + text-align: center; + + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; +} + +.openwebrx-meta-slot > * { + flex: 1 0 0; + line-height: 1.2em; +} + +.openwebrx-meta-slot, .openwebrx-meta-slot .mute { + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; +} + +.openwebrx-meta-slot .mute { + display: none; + cursor: pointer; + + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + background-color: rgba(0,0,0,.3); +} + +.openwebrx-meta-slot .mute svg { + position: absolute; + top: 50%; + left: 0; + transform: translate(0, -50%); +} + +.openwebrx-meta-slot.muted .mute { + display: block; +} + +.openwebrx-meta-slot.active { + background-color: #95bbdf; +} + +.openwebrx-meta-slot.sync .openwebrx-dmr-slot:before { + content:""; + display: inline-block; + margin: 0 5px; + width: 12px; + height: 12px; + background-color: #ABFF00; + border-radius: 50%; + box-shadow: rgba(0, 0, 0, 0.2) 0 -1px 7px 1px, inset #304701 0 -1px 9px, #89FF00 0 2px 12px; +} + +.openwebrx-meta-slot .openwebrx-meta-user-image { + flex: 0 1 100%; + background-position: center; + background-repeat: no-repeat; + line-height: 0; + overflow: hidden; +} + +.openwebrx-meta-slot .openwebrx-meta-user-image img { + max-width: 100%; + max-height: 100%; + display: none; +} + +.openwebrx-meta-slot.active.direct .openwebrx-meta-user-image .directcall, +.openwebrx-meta-slot.active.individual .openwebrx-meta-user-image .directcall, +#openwebrx-panel-metadata-ysf .openwebrx-meta-slot.active .openwebrx-meta-user-image .directcall, +#openwebrx-panel-metadata-dstar .openwebrx-meta-slot.active .openwebrx-meta-user-image .directcall { + display: initial; +} + +.openwebrx-meta-slot.active.group .openwebrx-meta-user-image .groupcall, +.openwebrx-meta-slot.active.conference .openwebrx-meta-user-image .groupcall { + display: initial; +} + +.openwebrx-meta-slot.group .openwebrx-dmr-target:not(:empty):before { + content: "Talkgroup: "; +} + +.openwebrx-meta-slot.direct .openwebrx-dmr-target:not(:empty):before { + content: "Direct: "; +} + +.openwebrx-dmr-timeslot-panel * { + cursor: pointer; + user-select: none; +} + +.openwebrx-ysf-mode:not(:empty):before { + content: "Mode: "; +} + +.openwebrx-ysf-up:not(:empty):before { + content: "Up: "; +} + +.openwebrx-ysf-down:not(:empty):before { + content: "Down: "; +} + +.openwebrx-dstar-yourcall:not(:empty):before { + content: "UR: "; +} + +.openwebrx-dstar-departure:not(:empty):before { + content: "RPT1: "; +} + +.openwebrx-dstar-destination:not(:empty):before { + content: "RPT2: "; +} + +.openwebrx-meta-slot.individual .openwebrx-nxdn-destination:not(:empty):before { + content: "Direct: "; +} + +.openwebrx-meta-slot.conference .openwebrx-nxdn-destination:not(:empty):before { + content: "Conference: "; +} + +.openwebrx-maps-pin svg { + width: 15px; + height: 15px; + vertical-align: middle; +} + +.openwebrx-message-panel { + height: 180px; + position: relative; +} + +.openwebrx-message-panel tbody { + display: block; + overflow: auto; + height: 150px; + width: 100%; +} + +.openwebrx-message-panel thead tr { + display: block; +} + +.openwebrx-message-panel th, +.openwebrx-message-panel td { + width: 50px; + text-align: left; + padding: 1px 3px; +} + +#openwebrx-panel-wsjt-message .message { + width: 380px; +} + +#openwebrx-panel-wsjt-message .decimal { + text-align: right; + width: 35px; +} + +#openwebrx-panel-wsjt-message .decimal.freq { + width: 70px; +} + +#openwebrx-panel-js8-message .message { + width: 465px; + max-width: 465px; +} + +#openwebrx-panel-js8-message td.message { + white-space: nowrap; + overflow: hidden; + display: flex; + flex-direction: row-reverse; +} + +#openwebrx-panel-js8-message .message div { + flex: 1; +} + +#openwebrx-panel-js8-message .decimal { + text-align: right; + width: 35px; +} + +#openwebrx-panel-js8-message .decimal.freq { + width: 70px; +} + +#openwebrx-panel-packet-message .message { + width: 410px; + max-width: 410px; +} + +#openwebrx-panel-packet-message .callsign { + width: 80px; +} + +#openwebrx-panel-packet-message .coord { + width: 40px; + text-align: center; +} + +#openwebrx-panel-pocsag-message .address { + width: 100px; +} + +#openwebrx-panel-pocsag-message .message { + width: 486px; + max-width: 486px; + white-space: pre; +} + +.aprs-symbol { + display: inline-block; + width: 15px; + height: 15px; + background-size: 240px 90px; +} + +.aprs-symboltable-normal { + background-image: url(../../aprs-symbols/aprs-symbols-24-0.png) +} + +.aprs-symboltable-alternate { + background-image: url(../../aprs-symbols/aprs-symbols-24-1.png) +} + +.aprs-symboltable-overlay { + background-image: url(../../aprs-symbols/aprs-symbols-24-2.png) +} + +.openwebrx-dialog { + background-color: #575757; + padding: 10px; + color: white; + position: fixed; + font-size: 10pt; + border-radius: 15px; + -moz-border-radius: 15px; + position: fixed; + left: 50%; + top: 50%; + transform: translate(-50%, 0); +} + +.openwebrx-dialog .form-field { + padding: 5px; + display: flex; + flex-direction: row; +} + +.openwebrx-dialog .form-field:first-child { + padding-top: 0; +} + +.openwebrx-dialog label { + display: inline-block; + flex-grow: 0; + width: 70px; + padding-right: 20px; + margin-top: auto; + margin-bottom: auto; +} + +.openwebrx-dialog .form-field input, +.openwebrx-dialog .form-field select { + flex-grow: 1; + height: 27px; +} + +.openwebrx-dialog .form-field input { + padding: 0 5px; +} + +.openwebrx-dialog .buttons { + text-align: right; + padding: 5px 5px 0; + border-top: 1px solid #666; +} + +.openwebrx-dialog .buttons .openwebrx-button { + font-size: 12pt; + min-width: 50px; + text-align: center; + padding: 5px 10px; +} + +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="packet"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="packet"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-select-channel +{ + display: none; +} + +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="packet"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-canvas-container +{ + height: 200px; + margin: -10px; +} + +.openwebrx-zoom-button svg { + height: 27px; +} + +.openwebrx-slider-button svg { + position:relative; + top: 1px; + height: 14px; +} + +.openwebrx-mute-button svg.muted { + display: none; +} + +.openwebrx-mute-button.muted svg.muted { + display: initial; +} + +.openwebrx-mute-button.muted svg.unmuted { + display: none; +} + +.bookmark .bookmark-actions .openwebrx-button svg { + height: 14px; +} + +#openwebrx-waterfall-colors-auto .continuous { + display: none; +} + +#openwebrx-waterfall-colors-auto.highlighted .continuous { + display: initial; +} + +#openwebrx-waterfall-colors-auto.highlighted .auto { + display: none; +} + +.openwebrx-waterfall-container { + flex-grow: 1; + display: flex; + flex-direction: column; + position: relative; +} + +.openwebrx-waterfall-container > * { + flex: 0 0 auto; +} \ No newline at end of file diff --git a/openwebrx/htdocs/favicon.ico b/openwebrx/htdocs/favicon.ico new file mode 100644 index 0000000..6a07f1b Binary files /dev/null and b/openwebrx/htdocs/favicon.ico differ diff --git a/openwebrx/htdocs/features.html b/openwebrx/htdocs/features.html new file mode 100644 index 0000000..53099b6 --- /dev/null +++ b/openwebrx/htdocs/features.html @@ -0,0 +1,25 @@ + + OpenWebRX Feature report + + + + + + + + + ${header} +
+ ${breadcrumb} +

OpenWebRX Feature Report

+ + + + + + + +
FeatureRequirementDescriptionAvailable
+ ${breadcrumb} +
+ \ No newline at end of file diff --git a/openwebrx/htdocs/features.js b/openwebrx/htdocs/features.js new file mode 100644 index 0000000..fef2817 --- /dev/null +++ b/openwebrx/htdocs/features.js @@ -0,0 +1,23 @@ +$(function(){ + var converter = new showdown.Converter(); + $.ajax('api/features').done(function(data){ + var $table = $('table.features'); + $.each(data, function(name, details) { + var requirements = $.map(details.requirements, function(r, name){ + return '' + + '' + + '' + name + '' + + '' + converter.makeHtml(r.description) + '' + + '' + (r.available ? 'YES' : 'NO') + '' + + ''; + }); + $table.append( + '' + + '' + name + '' + + '' + (details.available ? 'YES' : 'NO') + '' + + '' + + requirements.join("") + ); + }) + }); +}); diff --git a/openwebrx/htdocs/fonts/RobotoMono-Regular.ttf b/openwebrx/htdocs/fonts/RobotoMono-Regular.ttf new file mode 100644 index 0000000..7c4ce36 Binary files /dev/null and b/openwebrx/htdocs/fonts/RobotoMono-Regular.ttf differ diff --git a/openwebrx/htdocs/fonts/RobotoMono-Regular.woff b/openwebrx/htdocs/fonts/RobotoMono-Regular.woff new file mode 100644 index 0000000..1b805ea Binary files /dev/null and b/openwebrx/htdocs/fonts/RobotoMono-Regular.woff differ diff --git a/openwebrx/htdocs/fonts/RobotoMono-Regular.woff2 b/openwebrx/htdocs/fonts/RobotoMono-Regular.woff2 new file mode 100644 index 0000000..dab2585 Binary files /dev/null and b/openwebrx/htdocs/fonts/RobotoMono-Regular.woff2 differ diff --git a/openwebrx/htdocs/gfx/favicon128.png b/openwebrx/htdocs/gfx/favicon128.png new file mode 100644 index 0000000..ad42441 Binary files /dev/null and b/openwebrx/htdocs/gfx/favicon128.png differ diff --git a/openwebrx/htdocs/gfx/favicon32.png b/openwebrx/htdocs/gfx/favicon32.png new file mode 100644 index 0000000..2c534af Binary files /dev/null and b/openwebrx/htdocs/gfx/favicon32.png differ diff --git a/openwebrx/htdocs/gfx/favicon44.png b/openwebrx/htdocs/gfx/favicon44.png new file mode 100644 index 0000000..d21f326 Binary files /dev/null and b/openwebrx/htdocs/gfx/favicon44.png differ diff --git a/openwebrx/htdocs/gfx/favicon64.png b/openwebrx/htdocs/gfx/favicon64.png new file mode 100644 index 0000000..b08e03f Binary files /dev/null and b/openwebrx/htdocs/gfx/favicon64.png differ diff --git a/openwebrx/htdocs/gfx/favicon96.png b/openwebrx/htdocs/gfx/favicon96.png new file mode 100644 index 0000000..ee94129 Binary files /dev/null and b/openwebrx/htdocs/gfx/favicon96.png differ diff --git a/openwebrx/htdocs/gfx/openwebrx-avatar.png b/openwebrx/htdocs/gfx/openwebrx-avatar.png new file mode 100644 index 0000000..fc20529 Binary files /dev/null and b/openwebrx/htdocs/gfx/openwebrx-avatar.png differ diff --git a/openwebrx/htdocs/gfx/openwebrx-background-cool-blue.png b/openwebrx/htdocs/gfx/openwebrx-background-cool-blue.png new file mode 100644 index 0000000..236b366 Binary files /dev/null and b/openwebrx/htdocs/gfx/openwebrx-background-cool-blue.png differ diff --git a/openwebrx/htdocs/gfx/openwebrx-background-cool-blue.webp b/openwebrx/htdocs/gfx/openwebrx-background-cool-blue.webp new file mode 100644 index 0000000..51f7852 Binary files /dev/null and b/openwebrx/htdocs/gfx/openwebrx-background-cool-blue.webp differ diff --git a/openwebrx/htdocs/gfx/openwebrx-directcall.svg b/openwebrx/htdocs/gfx/openwebrx-directcall.svg new file mode 100644 index 0000000..3440112 --- /dev/null +++ b/openwebrx/htdocs/gfx/openwebrx-directcall.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/openwebrx/htdocs/gfx/openwebrx-groupcall.svg b/openwebrx/htdocs/gfx/openwebrx-groupcall.svg new file mode 100644 index 0000000..5083a57 --- /dev/null +++ b/openwebrx/htdocs/gfx/openwebrx-groupcall.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/openwebrx/htdocs/gfx/openwebrx-scale-background.png b/openwebrx/htdocs/gfx/openwebrx-scale-background.png new file mode 100644 index 0000000..91453c5 Binary files /dev/null and b/openwebrx/htdocs/gfx/openwebrx-scale-background.png differ diff --git a/openwebrx/htdocs/gfx/openwebrx-top-photo.jpg b/openwebrx/htdocs/gfx/openwebrx-top-photo.jpg new file mode 100644 index 0000000..afc8e7e Binary files /dev/null and b/openwebrx/htdocs/gfx/openwebrx-top-photo.jpg differ diff --git a/openwebrx/htdocs/gfx/svg-defs.svg b/openwebrx/htdocs/gfx/svg-defs.svg new file mode 100644 index 0000000..251b051 --- /dev/null +++ b/openwebrx/htdocs/gfx/svg-defs.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openwebrx/htdocs/include/header.include.html b/openwebrx/htdocs/include/header.include.html new file mode 100644 index 0000000..8ae0811 --- /dev/null +++ b/openwebrx/htdocs/include/header.include.html @@ -0,0 +1,25 @@ +
+
+ + Receiver avatar +
+

${receiver_name}

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

Status
+

Log
+

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

Colors

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

    Locator: ' + locator + '

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

    ' + callsign + '

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

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

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

    Bookmarks

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

    ${title}

    +
    + ${content} + ${breadcrumb} +
    +${modal} + \ No newline at end of file diff --git a/openwebrx/inkscape files/favicon.svg b/openwebrx/inkscape files/favicon.svg new file mode 100644 index 0000000..a7a6aa5 --- /dev/null +++ b/openwebrx/inkscape files/favicon.svg @@ -0,0 +1,2388 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/google_maps_pin.svg b/openwebrx/inkscape files/google_maps_pin.svg new file mode 100644 index 0000000..1dd4961 --- /dev/null +++ b/openwebrx/inkscape files/google_maps_pin.svg @@ -0,0 +1,76 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-bookmark.svg b/openwebrx/inkscape files/openwebrx-bookmark.svg new file mode 100644 index 0000000..39b786e --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-bookmark.svg @@ -0,0 +1,56 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-directcall.svg b/openwebrx/inkscape files/openwebrx-directcall.svg new file mode 100644 index 0000000..2489652 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-directcall.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-edit.svg b/openwebrx/inkscape files/openwebrx-edit.svg new file mode 100644 index 0000000..6cec2f2 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-edit.svg @@ -0,0 +1,52 @@ + +to editimage/svg+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_editimage/svg+xmlto edit diff --git a/openwebrx/inkscape files/openwebrx-groupcall.svg b/openwebrx/inkscape files/openwebrx-groupcall.svg new file mode 100644 index 0000000..8775878 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-groupcall.svg @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-logo.svg b/openwebrx/inkscape files/openwebrx-logo.svg new file mode 100644 index 0000000..6b352c2 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-logo.svg @@ -0,0 +1,161 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-mute.svg b/openwebrx/inkscape files/openwebrx-mute.svg new file mode 100644 index 0000000..0c24051 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-mute.svg @@ -0,0 +1,142 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-panel-log.svg b/openwebrx/inkscape files/openwebrx-panel-log.svg new file mode 100644 index 0000000..6648abb --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-panel-log.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-panel-map.svg b/openwebrx/inkscape files/openwebrx-panel-map.svg new file mode 100644 index 0000000..21ef46e --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-panel-map.svg @@ -0,0 +1,173 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-panel-receiver.svg b/openwebrx/inkscape files/openwebrx-panel-receiver.svg new file mode 100644 index 0000000..2472760 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-panel-receiver.svg @@ -0,0 +1,181 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-panel-settings.svg b/openwebrx/inkscape files/openwebrx-panel-settings.svg new file mode 100644 index 0000000..c8ba6a6 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-panel-settings.svg @@ -0,0 +1,115 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-panel-status.svg b/openwebrx/inkscape files/openwebrx-panel-status.svg new file mode 100644 index 0000000..049c564 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-panel-status.svg @@ -0,0 +1,146 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-play-button.svg b/openwebrx/inkscape files/openwebrx-play-button.svg new file mode 100644 index 0000000..5d7b756 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-play-button.svg @@ -0,0 +1,67 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-rx-details-arrow-down.svg b/openwebrx/inkscape files/openwebrx-rx-details-arrow-down.svg new file mode 100644 index 0000000..4b0755d --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-rx-details-arrow-down.svg @@ -0,0 +1,84 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-rx-details-arrow-up.svg b/openwebrx/inkscape files/openwebrx-rx-details-arrow-up.svg new file mode 100644 index 0000000..56a6a9c --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-rx-details-arrow-up.svg @@ -0,0 +1,82 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-speake-mutedr.svg b/openwebrx/inkscape files/openwebrx-speake-mutedr.svg new file mode 100644 index 0000000..fa25ccd --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-speake-mutedr.svg @@ -0,0 +1,129 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-speaker.svg b/openwebrx/inkscape files/openwebrx-speaker.svg new file mode 100644 index 0000000..ab5726a --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-speaker.svg @@ -0,0 +1,168 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-squelch.svg b/openwebrx/inkscape files/openwebrx-squelch.svg new file mode 100644 index 0000000..c6be1ee --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-squelch.svg @@ -0,0 +1,145 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-trashcan.svg b/openwebrx/inkscape files/openwebrx-trashcan.svg new file mode 100644 index 0000000..661fe50 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-trashcan.svg @@ -0,0 +1,63 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-waterfall-auto.svg b/openwebrx/inkscape files/openwebrx-waterfall-auto.svg new file mode 100644 index 0000000..34ec3cd --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-waterfall-auto.svg @@ -0,0 +1,84 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-waterfall-continuous.svg b/openwebrx/inkscape files/openwebrx-waterfall-continuous.svg new file mode 100644 index 0000000..f95c97e --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-waterfall-continuous.svg @@ -0,0 +1,120 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-waterfall-default.svg b/openwebrx/inkscape files/openwebrx-waterfall-default.svg new file mode 100644 index 0000000..51b5beb --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-waterfall-default.svg @@ -0,0 +1,123 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-zoom-in-total.svg b/openwebrx/inkscape files/openwebrx-zoom-in-total.svg new file mode 100644 index 0000000..dddb2bf --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-zoom-in-total.svg @@ -0,0 +1,149 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-zoom-in.svg b/openwebrx/inkscape files/openwebrx-zoom-in.svg new file mode 100644 index 0000000..d8dd9f8 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-zoom-in.svg @@ -0,0 +1,157 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-zoom-out-total.svg b/openwebrx/inkscape files/openwebrx-zoom-out-total.svg new file mode 100644 index 0000000..cd4b824 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-zoom-out-total.svg @@ -0,0 +1,169 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-zoom-out.svg b/openwebrx/inkscape files/openwebrx-zoom-out.svg new file mode 100644 index 0000000..3096d4e --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-zoom-out.svg @@ -0,0 +1,158 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/openwebrx.conf b/openwebrx/openwebrx.conf new file mode 100644 index 0000000..32a0f77 --- /dev/null +++ b/openwebrx/openwebrx.conf @@ -0,0 +1,10 @@ +[core] +data_directory = /var/lib/openwebrx +temporary_directory = /tmp + +[web] +port = 8073 + +[aprs] +# path to the aprs symbols repository (get it here: https://github.com/hessu/aprs-symbols) +symbols_path = /usr/share/aprs-symbols/png diff --git a/openwebrx/openwebrx.py b/openwebrx/openwebrx.py new file mode 100644 index 0000000..4232fae --- /dev/null +++ b/openwebrx/openwebrx.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from owrx.__main__ import main + +if __name__ == "__main__": + main() diff --git a/openwebrx/owrx/__main__.py b/openwebrx/owrx/__main__.py new file mode 100644 index 0000000..bac982f --- /dev/null +++ b/openwebrx/owrx/__main__.py @@ -0,0 +1,115 @@ +import logging + +# the linter will complain about this, but the logging must be configured before importing all the other modules +logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +from http.server import HTTPServer +from owrx.http import RequestHandler +from owrx.config.core import CoreConfig +from owrx.config import Config +from owrx.config.commands import MigrateCommand +from owrx.feature import FeatureDetector +from owrx.sdr import SdrService +from socketserver import ThreadingMixIn +from owrx.service import Services +from owrx.websocket import WebSocketConnection +from owrx.reporting import ReportingEngine +from owrx.version import openwebrx_version +from owrx.audio.queue import DecoderQueue +from owrx.admin import add_admin_parser, run_admin_action +import signal +import argparse + + +class ThreadedHttpServer(ThreadingMixIn, HTTPServer): + pass + + +class SignalException(Exception): + pass + + +def handleSignal(sig, frame): + raise SignalException("Received Signal {sig}".format(sig=sig)) + + +def main(): + parser = argparse.ArgumentParser(description="OpenWebRX - Open Source SDR Web App for Everyone!") + parser.add_argument("-v", "--version", action="store_true", help="Show the software version") + parser.add_argument("--debug", action="store_true", help="Set loglevel to DEBUG") + + moduleparser = parser.add_subparsers(title="Modules", dest="module") + adminparser = moduleparser.add_parser("admin", help="Administration actions") + add_admin_parser(adminparser) + + configparser = moduleparser.add_parser("config", help="Configuration actions") + configcommandparser = configparser.add_subparsers(title="Commands", dest="command") + + migrateparser = configcommandparser.add_parser("migrate", help="Migrate configuration files") + migrateparser.set_defaults(cls=MigrateCommand) + + args = parser.parse_args() + + # set loglevel to info for CLI commands + if args.module is not None and not args.debug: + logging.getLogger().setLevel(logging.INFO) + + if args.version: + print("OpenWebRX version {version}".format(version=openwebrx_version)) + elif args.module == "admin": + run_admin_action(adminparser, args) + elif args.module == "config": + run_admin_action(configparser, args) + else: + start_receiver() + + +def start_receiver(): + print( + """ + +OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE file in the package +_________________________________________________________________________________________________ + +Author contact info: Jakob Ketterl, DD5JFK +Documentation: https://github.com/jketterl/openwebrx/wiki +Support and info: https://groups.io/g/openwebrx + + """ + ) + + logger.info("OpenWebRX version {0} starting up...".format(openwebrx_version)) + + for sig in [signal.SIGINT, signal.SIGTERM]: + signal.signal(sig, handleSignal) + + # config warmup + Config.validateConfig() + coreConfig = CoreConfig() + + featureDetector = FeatureDetector() + if not featureDetector.is_available("core"): + logger.error( + "you are missing required dependencies to run openwebrx. " + "please check that the following core requirements are installed and up to date:" + ) + logger.error(", ".join(featureDetector.get_requirements("core"))) + return + + # Get error messages about unknown / unavailable features as soon as possible + # start up "always-on" sources right away + SdrService.getAllSources() + + Services.start() + + try: + server = ThreadedHttpServer(("0.0.0.0", coreConfig.get_web_port()), RequestHandler) + server.serve_forever() + except SignalException: + pass + + WebSocketConnection.closeAll() + Services.stop() + ReportingEngine.stopAll() + DecoderQueue.stopAll() diff --git a/openwebrx/owrx/__pycache__/__main__.cpython-37.pyc b/openwebrx/owrx/__pycache__/__main__.cpython-37.pyc new file mode 100644 index 0000000..b46ac07 Binary files /dev/null and b/openwebrx/owrx/__pycache__/__main__.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/aprs.cpython-37.pyc b/openwebrx/owrx/__pycache__/aprs.cpython-37.pyc new file mode 100644 index 0000000..571d864 Binary files /dev/null and b/openwebrx/owrx/__pycache__/aprs.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/bands.cpython-37.pyc b/openwebrx/owrx/__pycache__/bands.cpython-37.pyc new file mode 100644 index 0000000..8808718 Binary files /dev/null and b/openwebrx/owrx/__pycache__/bands.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/bookmarks.cpython-37.pyc b/openwebrx/owrx/__pycache__/bookmarks.cpython-37.pyc new file mode 100644 index 0000000..411dbeb Binary files /dev/null and b/openwebrx/owrx/__pycache__/bookmarks.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/breadcrumb.cpython-37.pyc b/openwebrx/owrx/__pycache__/breadcrumb.cpython-37.pyc new file mode 100644 index 0000000..fafed9d Binary files /dev/null and b/openwebrx/owrx/__pycache__/breadcrumb.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/client.cpython-37.pyc b/openwebrx/owrx/__pycache__/client.cpython-37.pyc new file mode 100644 index 0000000..bf405f6 Binary files /dev/null and b/openwebrx/owrx/__pycache__/client.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/command.cpython-37.pyc b/openwebrx/owrx/__pycache__/command.cpython-37.pyc new file mode 100644 index 0000000..7617f27 Binary files /dev/null and b/openwebrx/owrx/__pycache__/command.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/connection.cpython-37.pyc b/openwebrx/owrx/__pycache__/connection.cpython-37.pyc new file mode 100644 index 0000000..e20a45f Binary files /dev/null and b/openwebrx/owrx/__pycache__/connection.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/cpu.cpython-37.pyc b/openwebrx/owrx/__pycache__/cpu.cpython-37.pyc new file mode 100644 index 0000000..b7a675e Binary files /dev/null and b/openwebrx/owrx/__pycache__/cpu.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/details.cpython-37.pyc b/openwebrx/owrx/__pycache__/details.cpython-37.pyc new file mode 100644 index 0000000..1d7d353 Binary files /dev/null and b/openwebrx/owrx/__pycache__/details.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/dsp.cpython-37.pyc b/openwebrx/owrx/__pycache__/dsp.cpython-37.pyc new file mode 100644 index 0000000..22f85ce Binary files /dev/null and b/openwebrx/owrx/__pycache__/dsp.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/feature.cpython-37.pyc b/openwebrx/owrx/__pycache__/feature.cpython-37.pyc new file mode 100644 index 0000000..99206d9 Binary files /dev/null and b/openwebrx/owrx/__pycache__/feature.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/fft.cpython-37.pyc b/openwebrx/owrx/__pycache__/fft.cpython-37.pyc new file mode 100644 index 0000000..8f7fcc5 Binary files /dev/null and b/openwebrx/owrx/__pycache__/fft.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/http.cpython-37.pyc b/openwebrx/owrx/__pycache__/http.cpython-37.pyc new file mode 100644 index 0000000..cff50ff Binary files /dev/null and b/openwebrx/owrx/__pycache__/http.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/js8.cpython-37.pyc b/openwebrx/owrx/__pycache__/js8.cpython-37.pyc new file mode 100644 index 0000000..625f728 Binary files /dev/null and b/openwebrx/owrx/__pycache__/js8.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/jsons.cpython-37.pyc b/openwebrx/owrx/__pycache__/jsons.cpython-37.pyc new file mode 100644 index 0000000..c9f717c Binary files /dev/null and b/openwebrx/owrx/__pycache__/jsons.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/kiss.cpython-37.pyc b/openwebrx/owrx/__pycache__/kiss.cpython-37.pyc new file mode 100644 index 0000000..a6c6003 Binary files /dev/null and b/openwebrx/owrx/__pycache__/kiss.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/locator.cpython-37.pyc b/openwebrx/owrx/__pycache__/locator.cpython-37.pyc new file mode 100644 index 0000000..4d58fbe Binary files /dev/null and b/openwebrx/owrx/__pycache__/locator.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/map.cpython-37.pyc b/openwebrx/owrx/__pycache__/map.cpython-37.pyc new file mode 100644 index 0000000..7755097 Binary files /dev/null and b/openwebrx/owrx/__pycache__/map.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/meta.cpython-37.pyc b/openwebrx/owrx/__pycache__/meta.cpython-37.pyc new file mode 100644 index 0000000..075a042 Binary files /dev/null and b/openwebrx/owrx/__pycache__/meta.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/metrics.cpython-37.pyc b/openwebrx/owrx/__pycache__/metrics.cpython-37.pyc new file mode 100644 index 0000000..b4f7bd9 Binary files /dev/null and b/openwebrx/owrx/__pycache__/metrics.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/modes.cpython-37.pyc b/openwebrx/owrx/__pycache__/modes.cpython-37.pyc new file mode 100644 index 0000000..97e45ed Binary files /dev/null and b/openwebrx/owrx/__pycache__/modes.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/parser.cpython-37.pyc b/openwebrx/owrx/__pycache__/parser.cpython-37.pyc new file mode 100644 index 0000000..24c5870 Binary files /dev/null and b/openwebrx/owrx/__pycache__/parser.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/pocsag.cpython-37.pyc b/openwebrx/owrx/__pycache__/pocsag.cpython-37.pyc new file mode 100644 index 0000000..3d433e2 Binary files /dev/null and b/openwebrx/owrx/__pycache__/pocsag.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/receiverid.cpython-37.pyc b/openwebrx/owrx/__pycache__/receiverid.cpython-37.pyc new file mode 100644 index 0000000..dd9a453 Binary files /dev/null and b/openwebrx/owrx/__pycache__/receiverid.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/sdr.cpython-37.pyc b/openwebrx/owrx/__pycache__/sdr.cpython-37.pyc new file mode 100644 index 0000000..151df70 Binary files /dev/null and b/openwebrx/owrx/__pycache__/sdr.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/soapy.cpython-37.pyc b/openwebrx/owrx/__pycache__/soapy.cpython-37.pyc new file mode 100644 index 0000000..8359bc4 Binary files /dev/null and b/openwebrx/owrx/__pycache__/soapy.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/socket.cpython-37.pyc b/openwebrx/owrx/__pycache__/socket.cpython-37.pyc new file mode 100644 index 0000000..d0f45f0 Binary files /dev/null and b/openwebrx/owrx/__pycache__/socket.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/users.cpython-37.pyc b/openwebrx/owrx/__pycache__/users.cpython-37.pyc new file mode 100644 index 0000000..2dadf22 Binary files /dev/null and b/openwebrx/owrx/__pycache__/users.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/version.cpython-37.pyc b/openwebrx/owrx/__pycache__/version.cpython-37.pyc new file mode 100644 index 0000000..4933d2f Binary files /dev/null and b/openwebrx/owrx/__pycache__/version.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/waterfall.cpython-37.pyc b/openwebrx/owrx/__pycache__/waterfall.cpython-37.pyc new file mode 100644 index 0000000..55fe0d5 Binary files /dev/null and b/openwebrx/owrx/__pycache__/waterfall.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/websocket.cpython-37.pyc b/openwebrx/owrx/__pycache__/websocket.cpython-37.pyc new file mode 100644 index 0000000..29a3cb1 Binary files /dev/null and b/openwebrx/owrx/__pycache__/websocket.cpython-37.pyc differ diff --git a/openwebrx/owrx/__pycache__/wsjt.cpython-37.pyc b/openwebrx/owrx/__pycache__/wsjt.cpython-37.pyc new file mode 100644 index 0000000..61f36ad Binary files /dev/null and b/openwebrx/owrx/__pycache__/wsjt.cpython-37.pyc differ diff --git a/openwebrx/owrx/admin/__init__.py b/openwebrx/owrx/admin/__init__.py new file mode 100644 index 0000000..276d17f --- /dev/null +++ b/openwebrx/owrx/admin/__init__.py @@ -0,0 +1,60 @@ +from owrx.admin.commands import NewUser, DeleteUser, ResetPassword, ListUsers, DisableUser, EnableUser, HasUser +import sys +import traceback + + +def add_admin_parser(moduleparser): + subparsers = moduleparser.add_subparsers(title="Commands", dest="command") + + adduser_parser = subparsers.add_parser("adduser", help="Add a new user") + adduser_parser.add_argument("user", help="Username to be added") + adduser_parser.set_defaults(cls=NewUser) + + removeuser_parser = subparsers.add_parser("removeuser", help="Remove an existing user") + removeuser_parser.add_argument("user", help="Username to be remvoed") + removeuser_parser.set_defaults(cls=DeleteUser) + + resetpassword_parser = subparsers.add_parser("resetpassword", help="Reset a user's password") + resetpassword_parser.add_argument("user", help="Username to be remvoed") + resetpassword_parser.set_defaults(cls=ResetPassword) + + listusers_parser = subparsers.add_parser("listusers", help="List enabled users") + listusers_parser.add_argument("-a", "--all", action="store_true", help="Show all users (including disabled ones)") + listusers_parser.set_defaults(cls=ListUsers) + + disableuser_parser = subparsers.add_parser("disableuser", help="Disable a user") + disableuser_parser.add_argument("user", help="Username to be disabled") + disableuser_parser.set_defaults(cls=DisableUser) + + enableuser_parser = subparsers.add_parser("enableuser", help="Enable a user") + enableuser_parser.add_argument("user", help="Username to be enabled") + enableuser_parser.set_defaults(cls=EnableUser) + + hasuser_parser = subparsers.add_parser("hasuser", help="Test if a user exists") + hasuser_parser.add_argument("user", help="Username to be checked") + hasuser_parser.set_defaults(cls=HasUser) + + moduleparser.add_argument( + "--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)" + ) + moduleparser.add_argument("--silent", action="store_true", help="Ignore errors (useful for automation)") + + +def run_admin_action(parser, args): + if hasattr(args, "cls"): + command = args.cls() + else: + if not hasattr(args, "silent") or not args.silent: + parser.print_help() + sys.exit(1) + sys.exit(0) + + try: + command.run(args) + except Exception: + if not hasattr(args, "silent") or not args.silent: + print("Error running command:") + traceback.print_exc() + sys.exit(1) + sys.exit(0) + diff --git a/openwebrx/owrx/admin/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/admin/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..3f8789b Binary files /dev/null and b/openwebrx/owrx/admin/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/admin/__pycache__/commands.cpython-37.pyc b/openwebrx/owrx/admin/__pycache__/commands.cpython-37.pyc new file mode 100644 index 0000000..363b140 Binary files /dev/null and b/openwebrx/owrx/admin/__pycache__/commands.cpython-37.pyc differ diff --git a/openwebrx/owrx/admin/commands.py b/openwebrx/owrx/admin/commands.py new file mode 100644 index 0000000..8eca85e --- /dev/null +++ b/openwebrx/owrx/admin/commands.py @@ -0,0 +1,115 @@ +from abc import ABC, ABCMeta, abstractmethod +from getpass import getpass +from owrx.users import UserList, User, DefaultPasswordClass +import sys +import random +import string +import os + + +class Command(ABC): + @abstractmethod + def run(self, args): + pass + + +class UserCommand(Command, metaclass=ABCMeta): + def getPassword(self, args, username): + if args.noninteractive: + if "OWRX_PASSWORD" in os.environ: + password = os.environ["OWRX_PASSWORD"] + generated = False + else: + print("Generating password for user {username}...".format(username=username)) + password = self.getRandomPassword() + generated = True + print('Password for {username} is "{password}".'.format(username=username, password=password)) + print('This password is suitable for initial setup only, you will be asked to reset it on initial use.') + print('This password cannot be recovered from the system, please copy it now.') + else: + password = getpass("Please enter the new password for {username}: ".format(username=username)) + confirm = getpass("Please confirm the new password: ") + if password != confirm: + print("ERROR: Password mismatch.") + sys.exit(1) + generated = False + return password, generated + + def getRandomPassword(self, length=10): + printable = list(string.ascii_letters) + list(string.digits) + return ''.join(random.choices(printable, k=length)) + + +class NewUser(UserCommand): + def run(self, args): + username = args.user + userList = UserList() + # early test to bypass the password stuff if the user already exists + if username in userList: + raise KeyError("User {username} already exists".format(username=username)) + + password, generated = self.getPassword(args, username) + + print("Creating user {username}...".format(username=username)) + user = User(name=username, enabled=True, password=DefaultPasswordClass(password), must_change_password=generated) + userList.addUser(user) + + +class DeleteUser(UserCommand): + def run(self, args): + username = args.user + print("Deleting user {username}...".format(username=username)) + userList = UserList() + userList.deleteUser(username) + + +class ResetPassword(UserCommand): + def run(self, args): + username = args.user + password, generated = self.getPassword(args, username) + userList = UserList() + userList[username].setPassword(DefaultPasswordClass(password), must_change_password=generated) + # this is a change to an object in the list, not the list itself + # in this case, store() is explicit + userList.store() + + +class DisableUser(UserCommand): + def run(self, args): + username = args.user + userList = UserList() + userList[username].disable() + userList.store() + + +class EnableUser(UserCommand): + def run(self, args): + username = args.user + userList = UserList() + userList[username].enable() + userList.store() + + +class ListUsers(Command): + def run(self, args): + userList = UserList() + print("List of enabled users:") + for u in userList.values(): + if args.all or u.enabled: + print(" {name}".format(name=u.name)) + + +class HasUser(Command): + """ + internal command used by the debian config scripts to test if the admin user has already been created + """ + def run(self, args): + userList = UserList() + if args.user in userList: + if not args.silent: + print('User "{name}" exists.'.format(name=args.user)) + else: + if not args.silent: + print('User "{name}" does not exist.'.format(name=args.user)) + # in bash, a return code > 0 is interpreted as "false" + sys.exit(1) diff --git a/openwebrx/owrx/aprs.py b/openwebrx/owrx/aprs.py new file mode 100644 index 0000000..1ce7278 --- /dev/null +++ b/openwebrx/owrx/aprs.py @@ -0,0 +1,590 @@ +from owrx.kiss import KissDeframer +from owrx.map import Map, LatLngLocation +from owrx.bands import Bandplan +from owrx.metrics import Metrics, CounterMetric +from owrx.parser import Parser +from datetime import datetime, timezone +import re +import logging + +logger = logging.getLogger(__name__) + + +# speed is in knots... convert to metric (km/h) +knotsToKilometers = 1.852 +feetToMeters = 0.3048 +milesToKilometers = 1.609344 +inchesToMilimeters = 25.4 + + +def fahrenheitToCelsius(f): + return (f - 32) * 5 / 9 + + +# not sure what the correct encoding is. it seems TAPR has set utf-8 as a standard, but not everybody is following it. +encoding = "utf-8" + +# regex for altitute in comment field +altitudeRegex = re.compile("(^.*)\\/A=([0-9]{6})(.*$)") + +# regex for parsing third-party headers +thirdpartyeRegex = re.compile("^([a-zA-Z0-9-]+)>((([a-zA-Z0-9-]+\\*?,)*)([a-zA-Z0-9-]+\\*?)):(.*)$") + +# regex for getting the message id out of message +messageIdRegex = re.compile("^(.*){([0-9]{1,5})$") + +# regex to filter pseudo "WIDE" path elements +widePattern = re.compile("^WIDE[0-9]-[0-9]$") + + +def decodeBase91(input): + base = decodeBase91(input[:-1]) * 91 if len(input) > 1 else 0 + return base + (ord(input[-1]) - 33) + + +def getSymbolData(symbol, table): + return {"symbol": symbol, "table": table, "index": ord(symbol) - 33, "tableindex": ord(table) - 33} + + +class Ax25Parser(object): + def parse(self, ax25frame): + control_pid = ax25frame.find(bytes([0x03, 0xF0])) + if control_pid % 7 > 0: + logger.warning("aprs packet framing error: control/pid position not aligned with 7-octet callsign data") + + def chunks(l, n): + """Yield successive n-sized chunks from l.""" + for i in range(0, len(l), n): + yield l[i : i + n] + + return { + "destination": self.extractCallsign(ax25frame[0:7]), + "source": self.extractCallsign(ax25frame[7:14]), + "path": [self.extractCallsign(c) for c in chunks(ax25frame[14:control_pid], 7)], + "data": ax25frame[control_pid + 2 :], + } + + def extractCallsign(self, input): + cs = bytes([b >> 1 for b in input[0:6]]).decode(encoding, "replace").strip() + ssid = (input[6] & 0b00011110) >> 1 + if ssid > 0: + return "{callsign}-{ssid}".format(callsign=cs, ssid=ssid) + else: + return cs + + +class WeatherMapping(object): + def __init__(self, char, key, length, scale=None): + self.char = char + self.key = key + self.length = length + self.scale = scale + + def matches(self, input): + return self.char == input[0] and len(input) > self.length + + def updateWeather(self, weather, input): + def deepApply(obj, key, v): + keys = key.split(".") + if len(keys) > 1: + if not keys[0] in obj: + obj[keys[0]] = {} + deepApply(obj[keys[0]], ".".join(keys[1:]), v) + else: + obj[key] = v + + try: + value = int(input[1 : 1 + self.length]) + if self.scale: + value = self.scale(value) + deepApply(weather, self.key, value) + except ValueError: + pass + remain = input[1 + self.length :] + return weather, remain + + +class WeatherParser(object): + mappings = [ + WeatherMapping("c", "wind.direction", 3), + WeatherMapping("s", "wind.speed", 3, lambda x: x * milesToKilometers), + WeatherMapping("g", "wind.gust", 3, lambda x: x * milesToKilometers), + WeatherMapping("t", "temperature", 3, fahrenheitToCelsius), + WeatherMapping("r", "rain.hour", 3, lambda x: x / 100 * inchesToMilimeters), + WeatherMapping("p", "rain.day", 3, lambda x: x / 100 * inchesToMilimeters), + WeatherMapping("P", "rain.sincemidnight", 3, lambda x: x / 100 * inchesToMilimeters), + WeatherMapping("h", "humidity", 2), + WeatherMapping("b", "barometricpressure", 5, lambda x: x / 10), + WeatherMapping("s", "snowfall", 3, lambda x: x * 25.4), + ] + + def __init__(self, data, weather={}): + self.data = data + self.weather = weather + + def getWeather(self): + doWork = True + weather = self.weather + while doWork: + mapping = next((m for m in WeatherParser.mappings if m.matches(self.data)), None) + if mapping: + (weather, remain) = mapping.updateWeather(weather, self.data) + self.data = remain + doWork = len(self.data) > 0 + else: + doWork = False + return weather + + def getRemainder(self): + return self.data + + +class AprsLocation(LatLngLocation): + def __init__(self, data): + super().__init__(data["lat"], data["lon"]) + self.data = data + + def __dict__(self): + res = super(AprsLocation, self).__dict__() + for key in ["comment", "symbol", "course", "speed"]: + if key in self.data: + res[key] = self.data[key] + return res + + +class AprsParser(Parser): + def __init__(self, handler): + super().__init__(handler) + self.ax25parser = Ax25Parser() + self.deframer = KissDeframer() + self.metrics = {} + + def setDialFrequency(self, freq): + super().setDialFrequency(freq) + self.metrics = {} + + def getMetric(self, category): + if category not in self.metrics: + band = "unknown" + if self.band is not None: + band = self.band.getName() + name = "aprs.decodes.{band}.aprs.{category}".format(band=band, category=category) + metrics = Metrics.getSharedInstance() + self.metrics[category] = metrics.getMetric(name) + if self.metrics[category] is None: + self.metrics[category] = CounterMetric() + metrics.addMetric(name, self.metrics[category]) + return self.metrics[category] + + def isDirect(self, aprsData): + if "path" in aprsData and len(aprsData["path"]) > 0: + hops = [host for host in aprsData["path"] if widePattern.match(host) is None] + if len(hops) > 0: + return False + if "type" in aprsData and aprsData["type"] in ["thirdparty", "item", "object"]: + return False + return True + + def parse(self, raw): + for frame in self.deframer.parse(raw): + try: + data = self.ax25parser.parse(frame) + + # TODO how can we tell if this is an APRS frame at all? + aprsData = self.parseAprsData(data) + + logger.debug("decoded APRS data: %s", aprsData) + self.updateMap(aprsData) + self.getMetric("total").inc() + if self.isDirect(aprsData): + self.getMetric("direct").inc() + self.handler.write_aprs_data(aprsData) + except Exception: + logger.exception("exception while parsing aprs data") + + def updateMap(self, mapData): + if "type" in mapData and mapData["type"] == "thirdparty" and "data" in mapData: + mapData = mapData["data"] + if "lat" in mapData and "lon" in mapData: + loc = AprsLocation(mapData) + source = mapData["source"] + if "type" in mapData: + if mapData["type"] == "item": + source = mapData["item"] + elif mapData["type"] == "object": + source = mapData["object"] + Map.getSharedInstance().updateLocation(source, loc, "APRS", self.band) + + def hasCompressedCoordinates(self, raw): + return raw[0] == "/" or raw[0] == "\\" + + def parseUncompressedCoordinates(self, raw): + lat = int(raw[0:2]) + float(raw[2:7]) / 60 + if raw[7] == "S": + lat *= -1 + lon = int(raw[9:12]) + float(raw[12:17]) / 60 + if raw[17] == "W": + lon *= -1 + return {"lat": lat, "lon": lon, "symbol": getSymbolData(raw[18], raw[8])} + + def parseCompressedCoordinates(self, raw): + return { + "lat": 90 - decodeBase91(raw[1:5]) / 380926, + "lon": -180 + decodeBase91(raw[5:9]) / 190463, + "symbol": getSymbolData(raw[9], raw[0]), + } + + def parseTimestamp(self, raw): + now = datetime.now() + if raw[6] == "h": + ts = datetime.strptime(raw[0:6], "%H%M%S") + ts = ts.replace(year=now.year, month=now.month, day=now.month, tzinfo=timezone.utc) + else: + ts = datetime.strptime(raw[0:6], "%d%H%M") + ts = ts.replace(year=now.year, month=now.month) + if raw[6] == "z": + ts = ts.replace(tzinfo=timezone.utc) + elif raw[6] == "/": + ts = ts.replace(tzinfo=now.tzinfo) + else: + logger.warning("invalid timezone info byte: %s", raw[6]) + return int(ts.timestamp() * 1000) + + def parseStatusUpate(self, raw): + res = {"type": "status"} + if raw[6] == "z": + res["timestamp"] = self.parseTimestamp(raw[0:7]) + res["comment"] = raw[7:] + else: + res["comment"] = raw + return res + + def parseAprsData(self, data): + information = data["data"] + + # forward some of the ax25 data + aprsData = {"source": data["source"], "destination": data["destination"], "path": data["path"]} + + if information[0] == 0x1C or information[0] == ord("`") or information[0] == ord("'"): + aprsData.update(MicEParser().parse(data)) + return aprsData + + information = information.decode(encoding, "replace") + + # APRS data type identifier + dti = information[0] + + if dti == "!" or dti == "=": + # position without timestamp + aprsData.update(self.parseRegularAprsData(information[1:])) + elif dti == "/" or dti == "@": + # position with timestamp + aprsData["timestamp"] = self.parseTimestamp(information[1:8]) + aprsData.update(self.parseRegularAprsData(information[8:])) + elif dti == ">": + # status update + aprsData.update(self.parseStatusUpate(information[1:])) + elif dti == "}": + # third party + aprsData.update(self.parseThirdpartyAprsData(information[1:])) + elif dti == ":": + # message + aprsData.update(self.parseMessage(information[1:])) + elif dti == ";": + # object + aprsData.update(self.parseObject(information[1:])) + elif dti == ")": + # item + aprsData.update(self.parseItem(information[1:])) + + return aprsData + + def parseObject(self, information): + result = {"type": "object"} + if len(information) > 16: + result["object"] = information[0:9].strip() + result["live"] = information[9] == "*" + result["timestamp"] = self.parseTimestamp(information[10:17]) + result.update(self.parseRegularAprsData(information[17:])) + # override type, losing information about compression + result["type"] = "object" + return result + + def parseItem(self, information): + result = {"type": "item"} + if len(information) > 3: + indexes = [information[0:10].find(p) for p in ["!", "_"]] + filtered = [i for i in indexes if i >= 3] + filtered.sort() + if len(filtered): + index = filtered[0] + result["item"] = information[0:index] + result["live"] = information[index] == "!" + result.update(self.parseRegularAprsData(information[index + 1 :])) + # override type, losing information about compression + result["type"] = "item" + return result + + def parseMessage(self, information): + result = {"type": "message"} + if len(information) > 9 and information[9] == ":": + result["adressee"] = information[0:9] + message = information[10:] + if len(message) > 3 and message[0:3] == "ack": + result["type"] = "messageacknowledgement" + result["messageid"] = int(message[3:8]) + elif len(message) > 3 and message[0:3] == "rej": + result["type"] = "messagerejection" + result["messageid"] = int(message[3:8]) + else: + matches = messageIdRegex.match(message) + if matches: + result["messageid"] = int(matches.group(2)) + message = matches.group(1) + result["message"] = message + return result + + def parseThirdpartyAprsData(self, information): + matches = thirdpartyeRegex.match(information) + if matches: + path = matches.group(2).split(",") + destination = next((c.strip("*").upper() for c in path if c.endswith("*")), None) + data = self.parseAprsData( + { + "source": matches.group(1).upper(), + "destination": destination, + "path": path, + "data": matches.group(6).encode(encoding), + } + ) + return {"type": "thirdparty", "data": data} + + return {"type": "thirdparty"} + + def parseRegularAprsData(self, information): + if self.hasCompressedCoordinates(information): + aprsData = self.parseCompressedCoordinates(information[0:10]) + aprsData["type"] = "compressed" + if information[10] != " ": + if information[10] == "{": + # pre-calculated radio range + aprsData["range"] = 2 * 1.08 ** (ord(information[11]) - 33) * milesToKilometers + else: + aprsData["course"] = (ord(information[10]) - 33) * 4 + # speed is in knots... convert to metric (km/h) + aprsData["speed"] = (1.08 ** (ord(information[11]) - 33) - 1) * knotsToKilometers + # compression type + t = ord(information[12]) + aprsData["fix"] = (t & 0b00100000) > 0 + sources = ["other", "GLL", "GGA", "RMC"] + aprsData["nmeasource"] = sources[(t & 0b00011000) >> 3] + origins = [ + "Compressed", + "TNC BText", + "Software", + "[tbd]", + "KPC3", + "Pico", + "Other tracker", + "Digipeater conversion", + ] + aprsData["compressionorigin"] = origins[t & 0b00000111] + comment = information[13:] + else: + aprsData = self.parseUncompressedCoordinates(information[0:19]) + aprsData["type"] = "regular" + comment = information[19:] + + def decodeHeightGainDirectivity(comment): + res = {"height": 2 ** int(comment[4]) * 10 * feetToMeters, "gain": int(comment[5])} + directivity = int(comment[6]) + if directivity == 0: + res["directivity"] = "omni" + elif 0 < directivity < 9: + res["directivity"] = directivity * 45 + return res + + # aprs data extensions + # yes, weather stations are officially identified by their symbols. go figure... + if "symbol" in aprsData and aprsData["symbol"]["index"] == 62: + # weather report + weather = {} + if len(comment) > 6 and comment[3] == "/": + try: + weather["wind"] = {"direction": int(comment[0:3]), "speed": int(comment[4:7]) * milesToKilometers} + except ValueError: + pass + comment = comment[7:] + + parser = WeatherParser(comment, weather) + aprsData["weather"] = parser.getWeather() + comment = parser.getRemainder() + elif len(comment) > 6: + if comment[3] == "/": + # course and speed + # for a weather report, this would be wind direction and speed + try: + aprsData["course"] = int(comment[0:3]) + aprsData["speed"] = int(comment[4:7]) * knotsToKilometers + except ValueError: + pass + comment = comment[7:] + elif comment[0:3] == "PHG": + # station power and effective antenna height/gain/directivity + try: + powerCodes = [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] + aprsData["power"] = powerCodes[int(comment[3])] + aprsData.update(decodeHeightGainDirectivity(comment)) + except ValueError: + pass + comment = comment[7:] + elif comment[0:3] == "RNG": + # pre-calculated radio range + try: + aprsData["range"] = int(comment[3:7]) * milesToKilometers + except ValueError: + pass + comment = comment[7:] + elif comment[0:3] == "DFS": + # direction finding signal strength and antenna height/gain + try: + aprsData["strength"] = int(comment[3]) + aprsData.update(decodeHeightGainDirectivity(comment)) + except ValueError: + pass + comment = comment[7:] + + matches = altitudeRegex.match(comment) + if matches: + aprsData["altitude"] = int(matches.group(2)) * feetToMeters + comment = matches.group(1) + matches.group(3) + + aprsData["comment"] = comment + + return aprsData + + +class MicEParser(object): + def extractNumber(self, input): + n = ord(input) + if n >= ord("P"): + return n - ord("P") + if n >= ord("A"): + return n - ord("A") + return n - ord("0") + + def listToNumber(self, input): + base = self.listToNumber(input[:-1]) * 10 if len(input) > 1 else 0 + return base + input[-1] + + def extractAltitude(self, comment): + if len(comment) < 4 or comment[3] != "}": + return (comment, None) + return comment[4:], decodeBase91(comment[:3]) - 10000 + + def extractDevice(self, comment): + if len(comment) > 0: + if comment[0] == ">": + if len(comment) > 1: + if comment[-1] == "=": + return comment[1:-1], {"manufacturer": "Kenwood", "device": "TH-D72"} + if comment[-1] == "^": + return comment[1:-1], {"manufacturer": "Kenwood", "device": "TH-D74"} + return comment[1:], {"manufacturer": "Kenwood", "device": "TH-D7A"} + if comment[0] == "]": + if len(comment) > 1 and comment[-1] == "=": + return comment[1:-1], {"manufacturer": "Kenwood", "device": "TM-D710"} + return comment[1:], {"manufacturer": "Kenwood", "device": "TM-D700"} + if len(comment) > 2 and (comment[0] == "`" or comment[0] == "'"): + if comment[-2] == "_": + devices = { + "b": "VX-8", + '"': "FTM-350", + "#": "VX-8G", + "$": "FT1D", + "%": "FTM-400DR", + ")": "FTM-100D", + "(": "FT2D", + "0": "FT3D", + } + return comment[1:-2], {"manufacturer": "Yaesu", "device": devices.get(comment[-1], "Unknown")} + if comment[-2:] == " X": + return comment[1:-2], {"manufacturer": "SainSonic", "device": "AP510"} + if comment[-2] == "(": + devices = {"5": "D578UV", "8": "D878UV"} + return comment[1:-2], {"manufacturer": "Anytone", "device": devices.get(comment[-1], "Unknown")} + if comment[-2] == "|": + devices = {"3": "TinyTrack3", "4": "TinyTrack4"} + return comment[1:-2], {"manufacturer": "Byonics", "device": devices.get(comment[-1], "Unknown")} + if comment[-2:] == "^v": + return comment[1:-2], {"manufacturer": "HinzTec", "device": "anyfrog"} + if comment[-2] == ":": + devices = {"4": "P4dragon DR-7400 modem", "8": "P4dragon DR-7800 modem"} + return ( + comment[1:-2], + {"manufacturer": "SCS GmbH & Co.", "device": devices.get(comment[-1], "Unknown")}, + ) + if comment[-2:] == "~v": + return comment[1:-2], {"manufacturer": "Other", "device": "Other"} + return comment[1:-2], None + return comment, None + + def parse(self, data): + information = data["data"] + destination = data["destination"] + + rawLatitude = [self.extractNumber(c) for c in destination[0:6]] + lat = self.listToNumber(rawLatitude[0:2]) + self.listToNumber(rawLatitude[2:6]) / 6000 + if ord(destination[3]) <= ord("9"): + lat *= -1 + + lon = information[1] - 28 + if ord(destination[4]) >= ord("P"): + lon += 100 + if 180 <= lon <= 189: + lon -= 80 + if 190 <= lon <= 199: + lon -= 190 + + minutes = information[2] - 28 + if minutes >= 60: + minutes -= 60 + + lon += minutes / 60 + (information[3] - 28) / 6000 + + if ord(destination[5]) >= ord("P"): + lon *= -1 + + speed = (information[4] - 28) * 10 + dc28 = information[5] - 28 + speed += int(dc28 / 10) + course = (dc28 % 10) * 100 + course += information[6] - 28 + if speed >= 800: + speed -= 800 + if course >= 400: + course -= 400 + # speed is in knots... convert to metric (km/h) + speed *= knotsToKilometers + + comment = information[9:].decode(encoding, "replace").strip() + (comment, altitude) = self.extractAltitude(comment) + + (comment, device) = self.extractDevice(comment) + + # altitude might be inside the device string, so repeat and choose one + (comment, insideAltitude) = self.extractAltitude(comment) + altitude = next((a for a in [altitude, insideAltitude] if a is not None), None) + + return { + "fix": information[0] == ord("`") or information[0] == 0x1C, + "lat": lat, + "lon": lon, + "comment": comment, + "altitude": altitude, + "speed": speed, + "course": course, + "device": device, + "type": "Mic-E", + "symbol": getSymbolData(chr(information[7]), chr(information[8])), + } diff --git a/openwebrx/owrx/audio/__init__.py b/openwebrx/owrx/audio/__init__.py new file mode 100644 index 0000000..170bde3 --- /dev/null +++ b/openwebrx/owrx/audio/__init__.py @@ -0,0 +1,86 @@ +from owrx.config import Config +from abc import ABC, ABCMeta, abstractmethod +from typing import List + +import logging + +logger = logging.getLogger(__name__) + + +class AudioChopperProfile(ABC): + @abstractmethod + def getInterval(self): + pass + + @abstractmethod + def getFileTimestampFormat(self): + pass + + @abstractmethod + def decoder_commandline(self, file): + pass + + +class ProfileSourceSubscriber(ABC): + @abstractmethod + def onProfilesChanged(self): + pass + + +class ProfileSource(ABC): + def __init__(self): + self.subscribers = [] + + @abstractmethod + def getProfiles(self) -> List[AudioChopperProfile]: + pass + + def subscribe(self, subscriber: ProfileSourceSubscriber): + if subscriber in self.subscribers: + return + self.subscribers.append(subscriber) + + def unsubscribe(self, subscriber: ProfileSourceSubscriber): + if subscriber not in self.subscribers: + return + self.subscribers.remove(subscriber) + + def fireProfilesChanged(self): + for sub in self.subscribers.copy(): + try: + sub.onProfilesChanged() + except Exception: + logger.exception("Error while notifying profile subscriptions") + + +class ConfigWiredProfileSource(ProfileSource, metaclass=ABCMeta): + def __init__(self): + super().__init__() + self.configSub = None + + @abstractmethod + def getPropertiesToWire(self) -> List[str]: + pass + + def subscribe(self, subscriber: ProfileSourceSubscriber): + super().subscribe(subscriber) + if self.subscribers and self.configSub is None: + self.configSub = Config.get().filter(*self.getPropertiesToWire()).wire(self.fireProfilesChanged) + + def unsubscribe(self, subscriber: ProfileSourceSubscriber): + super().unsubscribe(subscriber) + if not self.subscribers and self.configSub is not None: + self.configSub.cancel() + self.configSub = None + + def fireProfilesChanged(self, *args): + super().fireProfilesChanged() + + +class StaticProfileSource(ProfileSource): + def __init__(self, profiles: List[AudioChopperProfile]): + super().__init__() + self.profiles = profiles + + def getProfiles(self) -> List[AudioChopperProfile]: + return self.profiles diff --git a/openwebrx/owrx/audio/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/audio/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..f8636f3 Binary files /dev/null and b/openwebrx/owrx/audio/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/audio/__pycache__/chopper.cpython-37.pyc b/openwebrx/owrx/audio/__pycache__/chopper.cpython-37.pyc new file mode 100644 index 0000000..d3c2fff Binary files /dev/null and b/openwebrx/owrx/audio/__pycache__/chopper.cpython-37.pyc differ diff --git a/openwebrx/owrx/audio/__pycache__/queue.cpython-37.pyc b/openwebrx/owrx/audio/__pycache__/queue.cpython-37.pyc new file mode 100644 index 0000000..f1a4806 Binary files /dev/null and b/openwebrx/owrx/audio/__pycache__/queue.cpython-37.pyc differ diff --git a/openwebrx/owrx/audio/__pycache__/wav.cpython-37.pyc b/openwebrx/owrx/audio/__pycache__/wav.cpython-37.pyc new file mode 100644 index 0000000..544fcae Binary files /dev/null and b/openwebrx/owrx/audio/__pycache__/wav.cpython-37.pyc differ diff --git a/openwebrx/owrx/audio/chopper.py b/openwebrx/owrx/audio/chopper.py new file mode 100644 index 0000000..4842432 --- /dev/null +++ b/openwebrx/owrx/audio/chopper.py @@ -0,0 +1,90 @@ +from owrx.modes import Modes, AudioChopperMode +from csdr.output import Output +from itertools import groupby +import threading +from owrx.audio import ProfileSourceSubscriber +from owrx.audio.wav import AudioWriter +from multiprocessing.connection import Pipe + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class AudioChopper(threading.Thread, Output, ProfileSourceSubscriber): + def __init__(self, active_dsp, mode_str: str): + self.read_fn = None + self.doRun = True + self.dsp = active_dsp + self.writers = [] + mode = Modes.findByModulation(mode_str) + if mode is None or not isinstance(mode, AudioChopperMode): + raise ValueError("Mode {} is not an audio chopper mode".format(mode_str)) + self.profile_source = mode.get_profile_source() + (self.outputReader, self.outputWriter) = Pipe() + super().__init__() + + def stop_writers(self): + while self.writers: + self.writers.pop().stop() + + def setup_writers(self): + self.stop_writers() + sorted_profiles = sorted(self.profile_source.getProfiles(), key=lambda p: p.getInterval()) + groups = {interval: list(group) for interval, group in groupby(sorted_profiles, key=lambda p: p.getInterval())} + writers = [ + AudioWriter(self.dsp, self.outputWriter, interval, profiles) for interval, profiles in groups.items() + ] + for w in writers: + w.start() + self.writers = writers + + def supports_type(self, t): + return t == "audio" + + def receive_output(self, t, read_fn): + self.read_fn = read_fn + self.start() + + def run(self) -> None: + logger.debug("Audio chopper starting up") + self.setup_writers() + self.profile_source.subscribe(self) + while self.doRun: + data = None + try: + data = self.read_fn(256) + except ValueError: + pass + if data is None or (isinstance(data, bytes) and len(data) == 0): + self.doRun = False + else: + for w in self.writers: + w.write(data) + + logger.debug("Audio chopper shutting down") + self.profile_source.unsubscribe(self) + self.stop_writers() + self.outputWriter.close() + self.outputWriter = None + + # drain messages left in the queue so that the queue can be successfully closed + # this is necessary since python keeps the file descriptors open otherwise + try: + while True: + self.outputReader.recv() + except EOFError: + pass + self.outputReader.close() + self.outputReader = None + + def onProfilesChanged(self): + logger.debug("profile change received, resetting writers...") + self.setup_writers() + + def read(self): + try: + return self.outputReader.recv() + except (EOFError, OSError): + return None diff --git a/openwebrx/owrx/audio/queue.py b/openwebrx/owrx/audio/queue.py new file mode 100644 index 0000000..daf27c8 --- /dev/null +++ b/openwebrx/owrx/audio/queue.py @@ -0,0 +1,172 @@ +from owrx.config import Config +from owrx.config.core import CoreConfig +from owrx.metrics import Metrics, CounterMetric, DirectMetric +from queue import Queue, Full, Empty +import subprocess +import os +import threading + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class QueueJob(object): + def __init__(self, profile, writer, file, freq): + self.profile = profile + self.writer = writer + self.file = file + self.freq = freq + + def run(self): + logger.debug("processing file %s", self.file) + tmp_dir = CoreConfig().get_temporary_directory() + decoder = subprocess.Popen( + ["nice", "-n", "10"] + self.profile.decoder_commandline(self.file), + stdout=subprocess.PIPE, + cwd=tmp_dir, + close_fds=True, + ) + try: + for line in decoder.stdout: + self.writer.send((self.profile, self.freq, line)) + except (OSError, AttributeError): + decoder.stdout.flush() + # TODO uncouple parsing from the output so that decodes can still go to the map and the spotters + logger.debug("output has gone away while decoding job.") + try: + rc = decoder.wait(timeout=10) + if rc != 0: + raise RuntimeError("decoder return code: {0}".format(rc)) + except subprocess.TimeoutExpired: + logger.warning("subprocess (pid=%i}) did not terminate correctly; sending kill signal.", decoder.pid) + decoder.kill() + raise + + def unlink(self): + try: + os.unlink(self.file) + except FileNotFoundError: + pass + + +PoisonPill = object() + + +class QueueWorker(threading.Thread): + def __init__(self, queue): + self.queue = queue + self.doRun = True + super().__init__() + + def run(self) -> None: + while self.doRun: + job = self.queue.get() + if job is PoisonPill: + self.stop() + else: + try: + job.run() + except Exception: + logger.exception("failed to decode job") + self.queue.onError() + finally: + job.unlink() + + self.queue.task_done() + + def stop(self): + self.doRun = False + + +class DecoderQueue(Queue): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with DecoderQueue.creationLock: + if DecoderQueue.sharedInstance is None: + DecoderQueue.sharedInstance = DecoderQueue() + return DecoderQueue.sharedInstance + + @staticmethod + def stopAll(): + with DecoderQueue.creationLock: + if DecoderQueue.sharedInstance is not None: + DecoderQueue.sharedInstance.stop() + DecoderQueue.sharedInstance = None + + def __init__(self): + pm = Config.get() + super().__init__(pm["decoding_queue_length"]) + self.workers = [] + self._setWorkers(pm["decoding_queue_workers"]) + self.subscriptions = [ + pm.wireProperty("decoding_queue_length", self._setMaxSize), + pm.wireProperty("decoding_queue_workers", self._setWorkers), + ] + metrics = Metrics.getSharedInstance() + metrics.addMetric("decoding.queue.length", DirectMetric(self.qsize)) + self.inCounter = CounterMetric() + metrics.addMetric("decoding.queue.in", self.inCounter) + self.outCounter = CounterMetric() + metrics.addMetric("decoding.queue.out", self.outCounter) + self.overflowCounter = CounterMetric() + metrics.addMetric("decoding.queue.overflow", self.overflowCounter) + self.errorCounter = CounterMetric() + metrics.addMetric("decoding.queue.error", self.errorCounter) + + def _setMaxSize(self, size): + if self.maxsize == size: + return + self.maxsize = size + + def _setWorkers(self, workers): + while len(self.workers) > workers: + logger.debug("stopping one worker") + self.workers.pop().stop() + while len(self.workers) < workers: + logger.debug("starting one worker") + self.workers.append(self.newWorker()) + + def stop(self): + logger.debug("shutting down the queue") + while self.subscriptions: + self.subscriptions.pop().cancel() + try: + # purge all remaining jobs + while not self.empty(): + job = self.get() + job.unlink() + self.task_done() + except Empty: + pass + # put() a PoisonPill for all active workers to shut them down + for w in self.workers: + if w.is_alive(): + self.put(PoisonPill) + self.join() + + def put(self, item, **kwargs): + self.inCounter.inc() + try: + super(DecoderQueue, self).put(item, block=False) + except Full: + self.overflowCounter.inc() + raise + + def get(self, **kwargs): + # super.get() is blocking, so it would mess up the stats to inc() first + out = super(DecoderQueue, self).get(**kwargs) + self.outCounter.inc() + return out + + def newWorker(self): + worker = QueueWorker(self) + worker.start() + return worker + + def onError(self): + self.errorCounter.inc() diff --git a/openwebrx/owrx/audio/wav.py b/openwebrx/owrx/audio/wav.py new file mode 100644 index 0000000..37af029 --- /dev/null +++ b/openwebrx/owrx/audio/wav.py @@ -0,0 +1,139 @@ +from owrx.config.core import CoreConfig +from owrx.audio import AudioChopperProfile +from owrx.audio.queue import QueueJob, DecoderQueue +import threading +import wave +import os +from datetime import datetime, timedelta +from queue import Full +from typing import List + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class WaveFile(object): + def __init__(self, writer_id): + self.timestamp = datetime.utcnow() + self.writer_id = writer_id + tmp_dir = CoreConfig().get_temporary_directory() + self.filename = "{tmp_dir}/openwebrx-audiochopper-master-{id}-{timestamp}.wav".format( + tmp_dir=tmp_dir, + id=self.writer_id, + timestamp=self.timestamp.strftime("%y%m%d_%H%M%S"), + ) + self.waveFile = wave.open(self.filename, "wb") + self.waveFile.setnchannels(1) + self.waveFile.setsampwidth(2) + self.waveFile.setframerate(12000) + + def close(self): + self.waveFile.close() + + def getFileName(self): + return self.filename + + def getTimestamp(self): + return self.timestamp + + def writeframes(self, data): + return self.waveFile.writeframes(data) + + def unlink(self): + os.unlink(self.filename) + self.waveFile = None + + +class AudioWriter(object): + def __init__(self, active_dsp, outputWriter, interval, profiles: List[AudioChopperProfile]): + self.dsp = active_dsp + self.outputWriter = outputWriter + self.interval = interval + self.profiles = profiles + self.wavefile = None + self.switchingLock = threading.Lock() + self.timer = None + + def getWaveFile(self): + return WaveFile(id(self)) + + def getNextDecodingTime(self): + # add one second to have the intervals tick over one second earlier + # this avoids filename collisions, but also avoids decoding wave files with less than one second of audio + t = datetime.utcnow() + timedelta(seconds=1) + zeroed = t.replace(minute=0, second=0, microsecond=0) + delta = t - zeroed + seconds = (int(delta.total_seconds() / self.interval) + 1) * self.interval + t = zeroed + timedelta(seconds=seconds) + logger.debug("scheduling: {0}".format(t)) + return t + + def cancelTimer(self): + if self.timer: + self.timer.cancel() + self.timer = None + + def _scheduleNextSwitch(self): + self.cancelTimer() + delta = self.getNextDecodingTime() - datetime.utcnow() + self.timer = threading.Timer(delta.total_seconds(), self.switchFiles) + self.timer.start() + + def switchFiles(self): + with self.switchingLock: + file = self.wavefile + self.wavefile = self.getWaveFile() + + file.close() + tmp_dir = CoreConfig().get_temporary_directory() + + for profile in self.profiles: + # create hardlinks for the individual profiles + filename = "{tmp_dir}/openwebrx-audiochopper-{pid}-{timestamp}.wav".format( + tmp_dir=tmp_dir, + pid=id(profile), + timestamp=file.getTimestamp().strftime(profile.getFileTimestampFormat()), + ) + try: + os.link(file.getFileName(), filename) + except OSError: + logger.exception("Error while linking job files") + continue + + job = QueueJob(profile, self.outputWriter, filename, self.dsp.get_operating_freq()) + try: + DecoderQueue.getSharedInstance().put(job) + except Full: + logger.warning("decoding queue overflow; dropping one file") + job.unlink() + + try: + # our master can be deleted now, the profiles will delete their hardlinked copies after processing + file.unlink() + except OSError: + logger.exception("Error while unlinking job files") + + self._scheduleNextSwitch() + + def start(self): + self.wavefile = self.getWaveFile() + self._scheduleNextSwitch() + + def write(self, data): + with self.switchingLock: + self.wavefile.writeframes(data) + + def stop(self): + self.cancelTimer() + try: + self.wavefile.close() + except Exception: + logger.exception("error closing wave file") + try: + with self.switchingLock: + self.wavefile.unlink() + except Exception: + logger.exception("error removing undecoded file") + self.wavefile = None diff --git a/openwebrx/owrx/bands.py b/openwebrx/owrx/bands.py new file mode 100644 index 0000000..1aba72e --- /dev/null +++ b/openwebrx/owrx/bands.py @@ -0,0 +1,111 @@ +from owrx.modes import Modes +from datetime import datetime, timezone +import json +import os + +import logging + +logger = logging.getLogger(__name__) + + +class Band(object): + def __init__(self, dict): + self.name = dict["name"] + self.lower_bound = dict["lower_bound"] + self.upper_bound = dict["upper_bound"] + self.frequencies = [] + if "frequencies" in dict: + availableModes = [mode.modulation for mode in Modes.getAvailableModes()] + for (mode, freqs) in dict["frequencies"].items(): + if mode not in availableModes: + logger.info( + 'Modulation "{mode}" is not available, bandplan bookmark will not be displayed'.format( + mode=mode + ) + ) + continue + if not isinstance(freqs, list): + freqs = [freqs] + for f in freqs: + if not self.inBand(f): + logger.warning( + "Frequency for {mode} on {band} is not within band limits: {frequency}".format( + mode=mode, frequency=f, band=self.name + ) + ) + continue + self.frequencies.append({"mode": mode, "frequency": f}) + + def inBand(self, freq): + return self.lower_bound <= freq <= self.upper_bound + + def getName(self): + return self.name + + def getDialFrequencies(self, range): + (low, hi) = range + return [e for e in self.frequencies if low <= e["frequency"] <= hi] + + +class Bandplan(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if Bandplan.sharedInstance is None: + Bandplan.sharedInstance = Bandplan() + return Bandplan.sharedInstance + + def __init__(self): + self.bands = [] + self.file_modified = None + self.fileList = ["/etc/openwebrx/bands.json", "bands.json"] + + def _refresh(self): + modified = self._getFileModifiedTimestamp() + if self.file_modified is None or modified > self.file_modified: + logger.debug("reloading bands from disk due to file modification") + self.bands = self._loadBands() + self.file_modified = modified + + def _getFileModifiedTimestamp(self): + timestamp = 0 + for file in self.fileList: + try: + timestamp = os.path.getmtime(file) + break + except FileNotFoundError: + pass + return datetime.fromtimestamp(timestamp, timezone.utc) + + def _loadBands(self): + for file in self.fileList: + try: + f = open(file, "r") + bands_json = json.load(f) + f.close() + return [Band(d) for d in bands_json] + except FileNotFoundError: + pass + except json.JSONDecodeError: + logger.exception("error while parsing bandplan file %s", file) + return [] + except Exception: + logger.exception("error while processing bandplan from %s", file) + return [] + return [] + + def findBands(self, freq): + self._refresh() + return [band for band in self.bands if band.inBand(freq)] + + def findBand(self, freq): + bands = self.findBands(freq) + if bands: + return bands[0] + else: + return None + + def collectDialFrequencies(self, range): + self._refresh() + return [e for b in self.bands for e in b.getDialFrequencies(range)] diff --git a/openwebrx/owrx/bookmarks.py b/openwebrx/owrx/bookmarks.py new file mode 100644 index 0000000..90819bd --- /dev/null +++ b/openwebrx/owrx/bookmarks.py @@ -0,0 +1,145 @@ +from datetime import datetime, timezone +from owrx.config.core import CoreConfig +import json +import os + +import logging + +logger = logging.getLogger(__name__) + + +class Bookmark(object): + def __init__(self, j): + self.name = j["name"] + self.frequency = j["frequency"] + self.modulation = j["modulation"] + + def getName(self): + return self.name + + def getFrequency(self): + return self.frequency + + def getModulation(self): + return self.modulation + + def __dict__(self): + return { + "name": self.getName(), + "frequency": self.getFrequency(), + "modulation": self.getModulation(), + } + + +class BookmakrSubscription(object): + def __init__(self, subscriptee, range, subscriber: callable): + self.subscriptee = subscriptee + self.range = range + self.subscriber = subscriber + + def inRange(self, bookmark: Bookmark): + low, high = self.range + return low <= bookmark.getFrequency() <= high + + def call(self, *args, **kwargs): + self.subscriber(*args, **kwargs) + + def cancel(self): + self.subscriptee.unsubscribe(self) + + +class Bookmarks(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if Bookmarks.sharedInstance is None: + Bookmarks.sharedInstance = Bookmarks() + return Bookmarks.sharedInstance + + def __init__(self): + self.file_modified = None + self.bookmarks = [] + self.subscriptions = [] + self.fileList = [Bookmarks._getBookmarksFile(), "/etc/openwebrx/bookmarks.json", "bookmarks.json"] + + def _refresh(self): + modified = self._getFileModifiedTimestamp() + if self.file_modified is None or modified > self.file_modified: + logger.debug("reloading bookmarks from disk due to file modification") + self.bookmarks = self._loadBookmarks() + self.file_modified = modified + + def _getFileModifiedTimestamp(self): + timestamp = 0 + for file in self.fileList: + try: + timestamp = os.path.getmtime(file) + break + except FileNotFoundError: + pass + return datetime.fromtimestamp(timestamp, timezone.utc) + + def _loadBookmarks(self): + for file in self.fileList: + try: + with open(file, "r") as f: + content = f.read() + if content: + bookmarks_json = json.loads(content) + return [Bookmark(d) for d in bookmarks_json] + except FileNotFoundError: + pass + except json.JSONDecodeError: + logger.exception("error while parsing bookmarks file %s", file) + return [] + except Exception: + logger.exception("error while processing bookmarks from %s", file) + return [] + return [] + + def getBookmarks(self, range=None): + self._refresh() + if range is None: + return self.bookmarks + else: + (lo, hi) = range + return [b for b in self.bookmarks if lo <= b.getFrequency() <= hi] + + @staticmethod + def _getBookmarksFile(): + coreConfig = CoreConfig() + return "{data_directory}/bookmarks.json".format(data_directory=coreConfig.get_data_directory()) + + def store(self): + # don't write directly to file to avoid corruption on exceptions + jsonContent = json.dumps([b.__dict__() for b in self.bookmarks], indent=4) + with open(Bookmarks._getBookmarksFile(), "w") as file: + file.write(jsonContent) + self.file_modified = self._getFileModifiedTimestamp() + + def addBookmark(self, bookmark: Bookmark): + self.bookmarks.append(bookmark) + self.notifySubscriptions(bookmark) + + def removeBookmark(self, bookmark: Bookmark): + if bookmark not in self.bookmarks: + return + self.bookmarks.remove(bookmark) + self.notifySubscriptions(bookmark) + + def notifySubscriptions(self, bookmark: Bookmark): + for sub in self.subscriptions: + if sub.inRange(bookmark): + try: + sub.call() + except Exception: + logger.exception("Error while calling bookmark subscriptions") + + def subscribe(self, range, callback): + self.subscriptions.append(BookmakrSubscription(self, range, callback)) + + def unsubscribe(self, subscriptions: BookmakrSubscription): + if subscriptions not in self.subscriptions: + return + self.subscriptions.remove(subscriptions) diff --git a/openwebrx/owrx/breadcrumb.py b/openwebrx/owrx/breadcrumb.py new file mode 100644 index 0000000..1a7d4f3 --- /dev/null +++ b/openwebrx/owrx/breadcrumb.py @@ -0,0 +1,44 @@ +from typing import List +from abc import ABC, abstractmethod + + +class BreadcrumbItem(object): + def __init__(self, title, href): + self.title = title + self.href = href + + def render(self, documentRoot, active=False): + return ''.format( + documentRoot=documentRoot, href=self.href, title=self.title, active="active" if active else "" + ) + + +class Breadcrumb(object): + def __init__(self, breadcrumbs: List[BreadcrumbItem]): + self.items = breadcrumbs + + def render(self, documentRoot): + return """ + + """.format( + crumbs="".join(item.render(documentRoot) for item in self.items[:-1]), + last_crumb="".join(item.render(documentRoot, True) for item in self.items[-1:]), + ) + + def append(self, crumb: BreadcrumbItem): + self.items.append(crumb) + return self + + +class BreadcrumbMixin(ABC): + def template_variables(self): + variables = super().template_variables() + variables["breadcrumb"] = self.get_breadcrumb().render(self.get_document_root()) + return variables + + @abstractmethod + def get_breadcrumb(self) -> Breadcrumb: + pass diff --git a/openwebrx/owrx/client.py b/openwebrx/owrx/client.py new file mode 100644 index 0000000..8ec7f4d --- /dev/null +++ b/openwebrx/owrx/client.py @@ -0,0 +1,54 @@ +from owrx.config import Config +import threading + +import logging + +logger = logging.getLogger(__name__) + + +class TooManyClientsException(Exception): + pass + + +class ClientRegistry(object): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with ClientRegistry.creationLock: + if ClientRegistry.sharedInstance is None: + ClientRegistry.sharedInstance = ClientRegistry() + return ClientRegistry.sharedInstance + + def __init__(self): + self.clients = [] + Config.get().wireProperty("max_clients", self._checkClientCount) + super().__init__() + + def broadcast(self): + n = self.clientCount() + for c in self.clients: + c.write_clients(n) + + def addClient(self, client): + pm = Config.get() + if len(self.clients) >= pm["max_clients"]: + raise TooManyClientsException() + self.clients.append(client) + self.broadcast() + + def clientCount(self): + return len(self.clients) + + def removeClient(self, client): + try: + self.clients.remove(client) + except ValueError: + pass + self.broadcast() + + def _checkClientCount(self, new_count): + for client in self.clients[new_count:]: + logger.debug("closing one connection...") + client.close() diff --git a/openwebrx/owrx/command.py b/openwebrx/owrx/command.py new file mode 100644 index 0000000..0559b72 --- /dev/null +++ b/openwebrx/owrx/command.py @@ -0,0 +1,79 @@ +from abc import ABC, abstractmethod + + +class CommandMapper(object): + def __init__(self, base=None, mappings=None, static=None): + self.base = base + self.mappings = {} if mappings is None else mappings + self.static = static + + def map(self, values): + args = [self.mappings[k].map(v) for k, v in values.items() if k in self.mappings] + args = [a for a in args if a != ""] + options = " ".join(args) + command = "{0} {1}".format(self.base, options) + if self.static is not None: + command += " " + self.static + return command + + def setMapping(self, key, mapping): + self.mappings[key] = mapping + return self + + def setMappings(self, mappings): + for k, v in mappings.items(): + self.setMapping(k, v) + return self + + def setBase(self, base): + self.base = base + return self + + def setStatic(self, static): + self.static = static + return self + + def keys(self): + return self.mappings.keys() + + +class CommandMapping(ABC): + @abstractmethod + def map(self, value): + pass + + +class Flag(CommandMapping): + def __init__(self, flag): + self.flag = flag + + def map(self, value): + if value is not None and value: + return self.flag + else: + return "" + + +class Option(CommandMapping): + def __init__(self, option): + self.option = option + self.spacer = " " + + def map(self, value): + if value is not None: + if isinstance(value, str) and " " in value: + template = '{option}{spacer}"{value}"' + else: + template = "{option}{spacer}{value}" + return template.format(option=self.option, spacer=self.spacer, value=value) + else: + return "" + + def setSpacer(self, spacer): + self.spacer = spacer + return self + + +class Argument(CommandMapping): + def map(self, value): + return str(value) diff --git a/openwebrx/owrx/config/__init__.py b/openwebrx/owrx/config/__init__.py new file mode 100644 index 0000000..bbd0d57 --- /dev/null +++ b/openwebrx/owrx/config/__init__.py @@ -0,0 +1,43 @@ +from owrx.property import PropertyStack +from owrx.config.error import ConfigError +from owrx.config.defaults import defaultConfig +from owrx.config.dynamic import DynamicConfig +from owrx.config.classic import ClassicConfig + + +class Config(PropertyStack): + sharedConfig = None + + def __init__(self): + super().__init__() + self.storableConfig = DynamicConfig() + layers = [ + self.storableConfig, + ClassicConfig(), + defaultConfig, + ] + for i, l in enumerate(layers): + self.addLayer(i, l) + + @staticmethod + def get(): + if Config.sharedConfig is None: + Config.sharedConfig = Config() + return Config.sharedConfig + + def store(self): + self.storableConfig.store() + + @staticmethod + def validateConfig(): + # no config checks atm + # just basic loading verification + Config.get() + + def __setitem__(self, key, value): + # in the config, all writes go to the json layer + return self.storableConfig.__setitem__(key, value) + + def __delitem__(self, key): + # all deletes go to the json layer, too + return self.storableConfig.__delitem__(key) diff --git a/openwebrx/owrx/config/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..02a4484 Binary files /dev/null and b/openwebrx/owrx/config/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/config/__pycache__/classic.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/classic.cpython-37.pyc new file mode 100644 index 0000000..b95a5c3 Binary files /dev/null and b/openwebrx/owrx/config/__pycache__/classic.cpython-37.pyc differ diff --git a/openwebrx/owrx/config/__pycache__/commands.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/commands.cpython-37.pyc new file mode 100644 index 0000000..480924d Binary files /dev/null and b/openwebrx/owrx/config/__pycache__/commands.cpython-37.pyc differ diff --git a/openwebrx/owrx/config/__pycache__/core.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/core.cpython-37.pyc new file mode 100644 index 0000000..629c493 Binary files /dev/null and b/openwebrx/owrx/config/__pycache__/core.cpython-37.pyc differ diff --git a/openwebrx/owrx/config/__pycache__/defaults.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/defaults.cpython-37.pyc new file mode 100644 index 0000000..6f382e7 Binary files /dev/null and b/openwebrx/owrx/config/__pycache__/defaults.cpython-37.pyc differ diff --git a/openwebrx/owrx/config/__pycache__/dynamic.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/dynamic.cpython-37.pyc new file mode 100644 index 0000000..b8befd7 Binary files /dev/null and b/openwebrx/owrx/config/__pycache__/dynamic.cpython-37.pyc differ diff --git a/openwebrx/owrx/config/__pycache__/error.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/error.cpython-37.pyc new file mode 100644 index 0000000..67a8805 Binary files /dev/null and b/openwebrx/owrx/config/__pycache__/error.cpython-37.pyc differ diff --git a/openwebrx/owrx/config/__pycache__/migration.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/migration.cpython-37.pyc new file mode 100644 index 0000000..9638fdc Binary files /dev/null and b/openwebrx/owrx/config/__pycache__/migration.cpython-37.pyc differ diff --git a/openwebrx/owrx/config/classic.py b/openwebrx/owrx/config/classic.py new file mode 100644 index 0000000..a91d57b --- /dev/null +++ b/openwebrx/owrx/config/classic.py @@ -0,0 +1,36 @@ +from owrx.property import PropertyReadOnly, PropertyLayer +from owrx.config.migration import Migrator +import importlib.util + + +class ClassicConfig(PropertyReadOnly): + def __init__(self): + pm = ClassicConfig._loadConfig() + Migrator.migrate(pm) + super().__init__(pm) + + @staticmethod + def _loadConfig(): + for file in ["/etc/openwebrx/config_webrx.py", "./config_webrx.py"]: + try: + return ClassicConfig._loadPythonFile(file) + except FileNotFoundError: + pass + return PropertyLayer() + + @staticmethod + def _toLayer(dictionary: dict): + layer = PropertyLayer() + for k, v in dictionary.items(): + if isinstance(v, dict): + layer[k] = ClassicConfig._toLayer(v) + else: + layer[k] = v + return layer + + @staticmethod + def _loadPythonFile(file): + spec = importlib.util.spec_from_file_location("config_webrx", file) + cfg = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cfg) + return ClassicConfig._toLayer({k: v for k, v in cfg.__dict__.items() if not k.startswith("__")}) diff --git a/openwebrx/owrx/config/commands.py b/openwebrx/owrx/config/commands.py new file mode 100644 index 0000000..153ca78 --- /dev/null +++ b/openwebrx/owrx/config/commands.py @@ -0,0 +1,30 @@ +from owrx.admin.commands import Command +from owrx.config import Config +from owrx.bookmarks import Bookmarks + + +class MigrateCommand(Command): + # these keys have been moved to openwebrx.conf + blacklisted_keys = [ + "temporary_directory", + "web_port", + "aprs_symbols_path", + ] + + def run(self, args): + print("Migrating configuration...") + + config = Config.get() + # a key that is set will end up in the DynamicConfig, so this will transfer everything there + for key, value in config.items(): + if key not in MigrateCommand.blacklisted_keys: + config[key] = value + config.store() + + print("Migrating bookmarks...") + # bookmarks just need to be saved + b = Bookmarks.getSharedInstance() + b.getBookmarks() + b.store() + + print("Migration complete!") diff --git a/openwebrx/owrx/config/core.py b/openwebrx/owrx/config/core.py new file mode 100644 index 0000000..e22f004 --- /dev/null +++ b/openwebrx/owrx/config/core.py @@ -0,0 +1,59 @@ +from owrx.config import ConfigError +from configparser import ConfigParser +import os +from glob import glob + + +class CoreConfig(object): + defaults = { + "core": { + "data_directory": "/var/lib/openwebrx", + "temporary_directory": "/tmp", + }, + "web": { + "port": 8073, + }, + "aprs": { + "symbols_path": "/usr/share/aprs-symbols/png" + } + } + + def __init__(self): + config = ConfigParser() + # set up config defaults + config.read_dict(CoreConfig.defaults) + # check for overrides + overrides_dir = "/etc/openwebrx/openwebrx.conf.d" + if os.path.exists(overrides_dir) and os.path.isdir(overrides_dir): + overrides = glob(overrides_dir + "/*.conf") + else: + overrides = [] + # sequence things together + config.read(["./openwebrx.conf", "/etc/openwebrx/openwebrx.conf"] + overrides) + self.data_directory = config.get("core", "data_directory") + CoreConfig.checkDirectory(self.data_directory, "data_directory") + self.temporary_directory = config.get("core", "temporary_directory") + CoreConfig.checkDirectory(self.temporary_directory, "temporary_directory") + self.web_port = config.getint("web", "port") + self.aprs_symbols_path = config.get("aprs", "symbols_path") + + @staticmethod + def checkDirectory(dir, key): + if not os.path.exists(dir): + raise ConfigError(key, "{dir} doesn't exist".format(dir=dir)) + if not os.path.isdir(dir): + raise ConfigError(key, "{dir} is not a directory".format(dir=dir)) + if not os.access(dir, os.W_OK): + raise ConfigError(key, "{dir} is not writable".format(dir=dir)) + + def get_web_port(self): + return self.web_port + + def get_data_directory(self): + return self.data_directory + + def get_temporary_directory(self): + return self.temporary_directory + + def get_aprs_symbols_path(self): + return self.aprs_symbols_path diff --git a/openwebrx/owrx/config/defaults.py b/openwebrx/owrx/config/defaults.py new file mode 100644 index 0000000..f03dded --- /dev/null +++ b/openwebrx/owrx/config/defaults.py @@ -0,0 +1,176 @@ +from owrx.property import PropertyLayer + + +defaultConfig = PropertyLayer( + version=7, + max_clients=20, + receiver_name="[Callsign]", + receiver_location="Budapest, Hungary", + receiver_asl=200, + receiver_admin="example@example.com", + receiver_gps=PropertyLayer(lat=47.0, lon=19.0), + photo_title="Panorama of Budapest from Schönherz Zoltán Dormitory", + photo_desc="", + fft_fps=9, + fft_size=4096, + fft_voverlap_factor=0.3, + audio_compression="adpcm", + fft_compression="adpcm", + wfm_deemphasis_tau=50e-6, + digimodes_fft_size=2048, + digital_voice_dmr_id_lookup=True, + digital_voice_nxdn_id_lookup=True, + sdrs=PropertyLayer( + rtlsdr=PropertyLayer( + name="RTL-SDR USB Stick", + type="rtl_sdr", + profiles=PropertyLayer( + **{ + "70cm": PropertyLayer( + name="70cm Repeaters", + center_freq=438800000, + rf_gain=29, + samp_rate=2400000, + start_freq=439275000, + start_mod="nfm", + ), + "2m": PropertyLayer( + name="2m", + center_freq=145000000, + rf_gain=29, + samp_rate=2048000, + start_freq=145725000, + start_mod="nfm", + ), + } + ), + ), + airspy=PropertyLayer( + name="Airspy HF+", + type="airspyhf", + rf_gain="auto", + profiles=PropertyLayer( + **{ + "20m": PropertyLayer( + name="20m", + center_freq=14150000, + samp_rate=384000, + start_freq=14070000, + start_mod="usb", + ), + "30m": PropertyLayer( + name="30m", + center_freq=10125000, + samp_rate=192000, + start_freq=10142000, + start_mod="usb", + ), + "40m": PropertyLayer( + name="40m", + center_freq=7100000, + samp_rate=256000, + start_freq=7070000, + start_mod="lsb", + ), + "80m": PropertyLayer( + name="80m", + center_freq=3650000, + samp_rate=384000, + start_freq=3570000, + start_mod="lsb", + ), + "49m": PropertyLayer( + name="49m Broadcast", + center_freq=6050000, + samp_rate=384000, + start_freq=6070000, + start_mod="am", + ), + } + ), + ), + sdrplay=PropertyLayer( + name="SDRPlay RSP2", + type="sdrplay", + antenna="Antenna A", + profiles=PropertyLayer( + **{ + "20m": PropertyLayer( + name="20m", + center_freq=14150000, + rf_gain=0, + samp_rate=500000, + start_freq=14070000, + start_mod="usb", + ), + "30m": PropertyLayer( + name="30m", + center_freq=10125000, + rf_gain=0, + samp_rate=250000, + start_freq=10142000, + start_mod="usb", + ), + "40m": PropertyLayer( + name="40m", + center_freq=7100000, + rf_gain=0, + samp_rate=500000, + start_freq=7070000, + start_mod="lsb", + ), + "80m": PropertyLayer( + name="80m", + center_freq=3650000, + rf_gain=0, + samp_rate=500000, + start_freq=3570000, + start_mod="lsb", + ), + "49m": PropertyLayer( + name="49m Broadcast", + center_freq=6000000, + rf_gain=0, + samp_rate=500000, + start_freq=6070000, + start_mod="am", + ), + } + ), + ), + ), + waterfall_scheme="GoogleTurboWaterfall", + waterfall_levels=PropertyLayer(min=-88, max=-20), + waterfall_auto_levels=PropertyLayer(min=3, max=10), + waterfall_auto_min_range=50, + tuning_precision=2, + squelch_auto_margin=10, + google_maps_api_key="", + map_position_retention_time=2 * 60 * 60, + decoding_queue_workers=2, + decoding_queue_length=10, + wsjt_decoding_depth=3, + wsjt_decoding_depths=PropertyLayer(jt65=1), + fst4_enabled_intervals=[15, 30], + fst4w_enabled_intervals=[120, 300], + q65_enabled_combinations=["A30", "E120", "C60"], + js8_enabled_profiles=["normal", "slow"], + js8_decoding_depth=3, + services_enabled=False, + services_decoders=["ft8", "ft4", "wspr", "packet"], + aprs_callsign="N0CALL", + aprs_igate_enabled=False, + aprs_igate_server="euro.aprs2.net", + aprs_igate_password="", + aprs_igate_beacon=False, + aprs_igate_symbol="R&", + aprs_igate_comment="OpenWebRX APRS gateway", + # aprs_igate_height=None, + # aprs_igate_gain=None, + # aprs_igate_dir=None, + pskreporter_enabled=False, + pskreporter_callsign="N0CALL", + # pskreporter_antenna_information=None, + wsprnet_enabled=False, + wsprnet_callsign="N0CALL", +).readonly() diff --git a/openwebrx/owrx/config/dynamic.py b/openwebrx/owrx/config/dynamic.py new file mode 100644 index 0000000..9357e05 --- /dev/null +++ b/openwebrx/owrx/config/dynamic.py @@ -0,0 +1,62 @@ +from owrx.config.core import CoreConfig +from owrx.config.migration import Migrator +from owrx.property import PropertyLayer, PropertyDeleted +from owrx.jsons import Encoder +import json + + +class DynamicConfig(PropertyLayer): + def __init__(self): + super().__init__() + try: + with open(DynamicConfig._getSettingsFile(), "r") as f: + for k, v in json.load(f).items(): + if isinstance(v, dict): + self[k] = DynamicConfig._toLayer(v) + else: + self[k] = v + except FileNotFoundError: + pass + Migrator.migrate(self) + + @staticmethod + def _toLayer(dictionary: dict): + layer = PropertyLayer() + for k, v in dictionary.items(): + if isinstance(v, dict): + layer[k] = DynamicConfig._toLayer(v) + else: + layer[k] = v + return layer + + @staticmethod + def _getSettingsFile(): + coreConfig = CoreConfig() + return "{data_directory}/settings.json".format(data_directory=coreConfig.get_data_directory()) + + def store(self): + # don't write directly to file to avoid corruption on exceptions + jsonContent = json.dumps(self.__dict__(), indent=4, cls=Encoder) + with open(DynamicConfig._getSettingsFile(), "w") as file: + file.write(jsonContent) + + def __delitem__(self, key): + self.__setitem__(key, PropertyDeleted) + + def __contains__(self, item): + if not super().__contains__(item): + return False + if super().__getitem__(item) is PropertyDeleted: + return False + return True + + def __getitem__(self, item): + if self.__contains__(item): + return super().__getitem__(item) + raise KeyError('Key "{key}" does not exist'.format(key=item)) + + def __dict__(self): + return {k: v for k, v in super().__dict__().items() if v is not PropertyDeleted} + + def keys(self): + return [k for k in super().keys() if self.__contains__(k)] diff --git a/openwebrx/owrx/config/error.py b/openwebrx/owrx/config/error.py new file mode 100644 index 0000000..19e1119 --- /dev/null +++ b/openwebrx/owrx/config/error.py @@ -0,0 +1,3 @@ +class ConfigError(Exception): + def __init__(self, key, message): + super().__init__("Configuration Error (key: {0}): {1}".format(key, message)) diff --git a/openwebrx/owrx/config/migration.py b/openwebrx/owrx/config/migration.py new file mode 100644 index 0000000..ec0068b --- /dev/null +++ b/openwebrx/owrx/config/migration.py @@ -0,0 +1,134 @@ +from abc import ABC, abstractmethod +from owrx.property import PropertyLayer + +import logging + +logger = logging.getLogger(__name__) + + +class ConfigMigrator(ABC): + @abstractmethod + def migrate(self, config): + pass + + def renameKey(self, config, old, new): + if old in config and new not in config: + config[new] = config[old] + del config[old] + + +class ConfigMigratorVersion1(ConfigMigrator): + def migrate(self, config): + if "receiver_gps" in config: + gps = config["receiver_gps"] + config["receiver_gps"] = {"lat": gps[0], "lon": gps[1]} + + if "waterfall_auto_level_margin" in config: + levels = config["waterfall_auto_level_margin"] + config["waterfall_auto_level_margin"] = {"min": levels[0], "max": levels[1]} + + self.renameKey(config, "wsjt_queue_workers", "decoding_queue_workers") + self.renameKey(config, "wsjt_queue_length", "decoding_queue_length") + + config["version"] = 2 + + +class ConfigMigratorVersion2(ConfigMigrator): + def migrate(self, config): + if "waterfall_colors" in config and any(v > 0xFFFFFF for v in config["waterfall_colors"]): + config["waterfall_colors"] = [v >> 8 for v in config["waterfall_colors"]] + + config["version"] = 3 + + +class ConfigMigratorVersion3(ConfigMigrator): + def migrate(self, config): + # inline import due to circular dependencies + from owrx.waterfall import WaterfallOptions + + if "waterfall_scheme" in config: + scheme = WaterfallOptions(config["waterfall_scheme"]) + if scheme is not WaterfallOptions.CUSTOM and "waterfall_colors" in config: + del config["waterfall_colors"] + elif "waterfall_colors" in config: + scheme = WaterfallOptions.findByColors(config["waterfall_colors"]) + if scheme is not WaterfallOptions.CUSTOM: + logger.debug("detected waterfall option: %s", scheme.value) + if "waterfall_colors" in config: + del config["waterfall_colors"] + config["waterfall_scheme"] = scheme.value + + config["version"] = 4 + + +class ConfigMigratorVersion4(ConfigMigrator): + def _replaceWaterfallLevels(self, instance): + if ( + "waterfall_min_level" in instance + and "waterfall_max_level" in instance + and not "waterfall_levels" in instance + ): + instance["waterfall_levels"] = { + "min": instance["waterfall_min_level"], + "max": instance["waterfall_max_level"], + } + del instance["waterfall_min_level"] + del instance["waterfall_max_level"] + + def migrate(self, config): + # migrate root level + self._replaceWaterfallLevels(config) + if "sdrs" in config: + for device in config["sdrs"].__dict__().values(): + # migrate device level + self._replaceWaterfallLevels(device) + if "profiles" in device: + for profile in device["profiles"].__dict__().values(): + # migrate profile level + self._replaceWaterfallLevels(profile) + + config["version"] = 5 + + +class ConfigMigratorVersion5(ConfigMigrator): + def migrate(self, config): + if "frequency_display_precision" in config: + # old config was always in relation to the display in MHz (1e6 Hz, hence the 6) + config["tuning_precision"] = 6 - config["frequency_display_precision"] + del config["frequency_display_precision"] + config["version"] = 6 + + +class ConfigMigratorVersion6(ConfigMigrator): + def migrate(self, config): + if "waterfall_auto_level_margin" in config: + walm_config = config["waterfall_auto_level_margin"] + if "min_range" in walm_config: + config["waterfall_auto_min_range"] = walm_config["min_range"] + wal = {k: v for k, v in walm_config.items() if k in ["min", "max"]} + config["waterfall_auto_levels"] = PropertyLayer(**wal) + del config["waterfall_auto_level_margin"] + config["version"] = 7 + + +class Migrator(object): + currentVersion = 7 + migrators = { + 1: ConfigMigratorVersion1(), + 2: ConfigMigratorVersion2(), + 3: ConfigMigratorVersion3(), + 4: ConfigMigratorVersion4(), + 5: ConfigMigratorVersion5(), + 6: ConfigMigratorVersion6(), + } + + @staticmethod + def migrate(config): + version = config["version"] if "version" in config else 1 + if version == Migrator.currentVersion: + return config + + logger.debug("migrating config from version %i", version) + migrators = [Migrator.migrators[i] for i in range(version, Migrator.currentVersion)] + for migrator in migrators: + migrator.migrate(config) diff --git a/openwebrx/owrx/connection.py b/openwebrx/owrx/connection.py new file mode 100644 index 0000000..a991eb8 --- /dev/null +++ b/openwebrx/owrx/connection.py @@ -0,0 +1,536 @@ +from owrx.details import ReceiverDetails +from owrx.dsp import DspManager +from owrx.cpu import CpuUsageThread +from owrx.sdr import SdrService +from owrx.source import SdrSourceState, SdrClientClass, SdrSourceEventClient +from owrx.client import ClientRegistry, TooManyClientsException +from owrx.feature import FeatureDetector +from owrx.version import openwebrx_version +from owrx.bands import Bandplan +from owrx.bookmarks import Bookmarks +from owrx.map import Map +from owrx.property import PropertyStack, PropertyDeleted +from owrx.modes import Modes, DigitalMode +from owrx.config import Config +from owrx.waterfall import WaterfallOptions +from owrx.websocket import Handler +from queue import Queue, Full, Empty +from js8py import Js8Frame +from abc import ABCMeta, abstractmethod +import json +import threading + +import logging + +logger = logging.getLogger(__name__) + +PoisonPill = object() + + +class Client(Handler, metaclass=ABCMeta): + def __init__(self, conn): + self.conn = conn + self.multithreadingQueue = Queue(100) + + def mp_passthru(): + run = True + while run: + try: + data = self.multithreadingQueue.get() + if data is PoisonPill: + run = False + else: + self.send(data) + self.multithreadingQueue.task_done() + except (EOFError, OSError, ValueError): + run = False + except Exception: + logger.exception("Exception on client multithreading queue") + + # unset the queue object to free shared memory file descriptors + self.multithreadingQueue = None + + threading.Thread(target=mp_passthru, name="connection_mp_passthru").start() + + def send(self, data): + try: + self.conn.send(data) + except IOError: + self.close() + + def close(self): + if self.multithreadingQueue is not None: + while True: + try: + self.multithreadingQueue.get(block=False) + except Empty: + break + try: + self.multithreadingQueue.put(PoisonPill, block=False) + except Full: + # this shouldn't happen, we just emptied the queue, but it's not worth risking the exception + logger.exception("impossible queue state: Full after Empty") + self.conn.close() + + def mp_send(self, data): + if self.multithreadingQueue is None: + return + try: + self.multithreadingQueue.put(data, block=False) + except Full: + self.close() + + @abstractmethod + def handleTextMessage(self, conn, message): + pass + + def handleBinaryMessage(self, conn, data): + logger.error("unsupported binary message, discarding") + + def handleClose(self): + self.close() + + +class OpenWebRxClient(Client, metaclass=ABCMeta): + def __init__(self, conn): + super().__init__(conn) + + receiver_details = ReceiverDetails() + + def send_receiver_info(*args): + receiver_info = receiver_details.__dict__() + self.write_receiver_details(receiver_info) + + self._detailsSubscription = receiver_details.wire(send_receiver_info) + send_receiver_info() + + def write_receiver_details(self, details): + self.send({"type": "receiver_details", "value": details}) + + def close(self): + self._detailsSubscription.cancel() + super().close() + + +class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): + sdr_config_keys = [ + "waterfall_levels", + "samp_rate", + "start_mod", + "start_freq", + "center_freq", + "initial_squelch_level", + "sdr_id", + "profile_id", + "squelch_auto_margin", + ] + + global_config_keys = [ + "waterfall_scheme", + "waterfall_colors", + "waterfall_auto_levels", + "waterfall_auto_min_range", + "fft_size", + "audio_compression", + "fft_compression", + "max_clients", + "tuning_precision", + ] + + def __init__(self, conn): + super().__init__(conn) + + self.dsp = None + self.dspLock = threading.Lock() + self.sdr = None + self.configSubs = [] + self.bookmarkSub = None + self.connectionProperties = {} + + try: + ClientRegistry.getSharedInstance().addClient(self) + except TooManyClientsException: + self.write_backoff_message("Too many clients") + self.close() + raise + + self.setupGlobalConfig() + self.stack = self.setupStack() + + self.setSdr() + + features = FeatureDetector().feature_availability() + self.write_features(features) + + modes = Modes.getModes() + self.write_modes(modes) + + self.configSubs.append(SdrService.getActiveSources().wire(self._onSdrDeviceChanges)) + self.configSubs.append(SdrService.getAvailableProfiles().wire(self._sendProfiles)) + self._sendProfiles() + + CpuUsageThread.getSharedInstance().add_client(self) + + def setupStack(self): + stack = PropertyStack() + # stack layer 0 reserved for sdr properties + # stack.addLayer(0, self.sdr.getProps()) + stack.addLayer(1, Config.get()) + configProps = stack.filter(*OpenWebRxReceiverClient.sdr_config_keys) + + def sendConfig(changes=None): + if changes is None: + config = configProps.__dict__() + else: + # transform deletions into Nones + config = {k: v if v is not PropertyDeleted else None for k, v in changes.items()} + if ( + (changes is None or "start_freq" in changes or "center_freq" in changes) + and "start_freq" in configProps + and "center_freq" in configProps + ): + config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"] + if (changes is None or "profile_id" in changes) and self.sdr is not None: + config["sdr_id"] = self.sdr.getId() + self.write_config(config) + + def sendBookmarks(*args): + cf = configProps["center_freq"] + srh = configProps["samp_rate"] / 2 + dial_frequencies = [] + bookmarks = [] + if "center_freq" in configProps and "samp_rate" in configProps: + frequencyRange = (cf - srh, cf + srh) + dial_frequencies = Bandplan.getSharedInstance().collectDialFrequencies(frequencyRange) + bookmarks = [b.__dict__() for b in Bookmarks.getSharedInstance().getBookmarks(frequencyRange)] + self.write_dial_frequencies(dial_frequencies) + self.write_bookmarks(bookmarks) + + def updateBookmarkSubscription(*args): + if self.bookmarkSub is not None: + self.bookmarkSub.cancel() + if "center_freq" in configProps and "samp_rate" in configProps: + cf = configProps["center_freq"] + srh = configProps["samp_rate"] / 2 + frequencyRange = (cf - srh, cf + srh) + self.bookmarkSub = Bookmarks.getSharedInstance().subscribe(frequencyRange, sendBookmarks) + sendBookmarks() + + self.configSubs.append(configProps.wire(sendConfig)) + self.configSubs.append(stack.filter("center_freq", "samp_rate").wire(updateBookmarkSubscription)) + + # send initial config + sendConfig() + return stack + + def setupGlobalConfig(self): + def writeConfig(changes): + # TODO it would be nicer to have all options available and switchable in the client + # this restores the existing functionality for now, but there is lots of potential + if "waterfall_scheme" in changes or "waterfall_colors" in changes: + scheme = WaterfallOptions(globalConfig["waterfall_scheme"]).instantiate() + changes["waterfall_colors"] = scheme.getColors() + self.write_config(changes) + + globalConfig = Config.get().filter(*OpenWebRxReceiverClient.global_config_keys) + self.configSubs.append(globalConfig.wire(writeConfig)) + writeConfig(globalConfig.__dict__()) + + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.RUNNING: + self.handleSdrAvailable() + + def onFail(self): + logger.warning('SDR device "%s" has failed, selecting new device', self.sdr.getName()) + self.write_log_message('SDR device "{0}" has failed, selecting new device'.format(self.sdr.getName())) + self.setSdr() + + def onDisable(self): + logger.warning('SDR device "%s" was disabled, selecting new device', self.sdr.getName()) + self.write_log_message('SDR device "{0}" was disabled, selecting new device'.format(self.sdr.getName())) + self.setSdr() + + def onShutdown(self): + logger.warning('SDR device "%s" is shutting down, selecting new device', self.sdr.getName()) + self.write_log_message('SDR device "{0}" is shutting down, selecting new device'.format(self.sdr.getName())) + self.setSdr() + + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.USER + + def _onSdrDeviceChanges(self, changes): + # restart the client if an sdr has become available + if self.sdr is None and any(s is not PropertyDeleted for s in changes.values()): + self.setSdr() + + def _sendProfiles(self, *args): + profiles = [{"id": pid, "name": name} for pid, name in SdrService.getAvailableProfiles().items()] + self.write_profiles(profiles) + + def handleTextMessage(self, conn, message): + try: + message = json.loads(message) + if "type" in message: + if message["type"] == "dspcontrol": + dsp = self.getDsp() + if dsp is None: + logger.warning("DSP not available; discarding client dspcontrol message") + else: + if "action" in message and message["action"] == "start": + dsp.start() + + if "params" in message: + params = message["params"] + dsp.setProperties(params) + + elif message["type"] == "setsdr": + if "params" in message: + self.setSdr(message["params"]["sdr"]) + elif message["type"] == "selectprofile": + if "params" in message and "profile" in message["params"]: + profile = message["params"]["profile"].split("|") + self.setSdr(profile[0]) + self.sdr.activateProfile(profile[1]) + elif message["type"] == "connectionproperties": + if "params" in message: + self.connectionProperties = message["params"] + if self.dsp: + self.getDsp().setProperties(self.connectionProperties) + + else: + logger.warning("received message without type: {0}".format(message)) + + except json.JSONDecodeError: + logger.warning("message is not json: {0}".format(message)) + + def setSdr(self, id=None): + next = None + if id is not None: + next = SdrService.getSource(id) + if next is None: + next = SdrService.getFirstSource() + + # exit condition: no change + if next == self.sdr and next is not None: + return + + self.stopDsp() + self.stack.removeLayerByPriority(0) + + if self.sdr is not None: + self.sdr.removeClient(self) + + self.sdr = next + + if next is None: + # exit condition: no sdrs available + logger.warning("no more SDR devices available") + self.handleNoSdrsAvailable() + return + + self.sdr.addClient(self) + + def handleSdrAvailable(self): + self.getDsp().setProperties(self.connectionProperties) + self.stack.replaceLayer(0, self.sdr.getProps()) + + self.sdr.addSpectrumClient(self) + + def handleNoSdrsAvailable(self): + self.write_sdr_error("No SDR Devices available") + + def close(self): + if self.sdr is not None: + self.sdr.removeClient(self) + self.stopDsp() + CpuUsageThread.getSharedInstance().remove_client(self) + ClientRegistry.getSharedInstance().removeClient(self) + while self.configSubs: + self.configSubs.pop().cancel() + if self.bookmarkSub is not None: + self.bookmarkSub.cancel() + self.bookmarkSub = None + super().close() + + def stopDsp(self): + with self.dspLock: + if self.dsp is not None: + self.dsp.stop() + self.dsp = None + if self.sdr is not None: + self.sdr.removeSpectrumClient(self) + + def getDsp(self): + with self.dspLock: + if self.dsp is None and self.sdr is not None: + self.dsp = DspManager(self, self.sdr) + return self.dsp + + def write_spectrum_data(self, data): + self.mp_send(bytes([0x01]) + data) + + def write_dsp_data(self, data): + self.send(bytes([0x02]) + data) + + def write_hd_audio(self, data): + self.send(bytes([0x04]) + data) + + def write_s_meter_level(self, level): + try: + self.send({"type": "smeter", "value": level}) + except ValueError: + logger.warning("unable to send smeter value: %s", str(level)) + + def write_cpu_usage(self, usage): + self.mp_send({"type": "cpuusage", "value": usage}) + + def write_clients(self, clients): + self.mp_send({"type": "clients", "value": clients}) + + def write_secondary_fft(self, data): + self.send(bytes([0x03]) + data) + + def write_secondary_demod(self, data): + message = data.decode("ascii", "replace") + self.send({"type": "secondary_demod", "value": message}) + + def write_secondary_dsp_config(self, cfg): + self.send({"type": "secondary_config", "value": cfg}) + + def write_config(self, cfg): + self.send({"type": "config", "value": cfg}) + + def write_profiles(self, profiles): + self.send({"type": "profiles", "value": profiles}) + + def write_features(self, features): + self.send({"type": "features", "value": features}) + + def write_metadata(self, metadata): + self.send({"type": "metadata", "value": metadata}) + + def write_wsjt_message(self, message): + self.send({"type": "wsjt_message", "value": message}) + + def write_dial_frequencies(self, frequencies): + self.send({"type": "dial_frequencies", "value": frequencies}) + + def write_bookmarks(self, bookmarks): + self.send({"type": "bookmarks", "value": bookmarks}) + + def write_aprs_data(self, data): + self.send({"type": "aprs_data", "value": data}) + + def write_log_message(self, message): + self.send({"type": "log_message", "value": message}) + + def write_sdr_error(self, message): + self.send({"type": "sdr_error", "value": message}) + + def write_pocsag_data(self, data): + self.send({"type": "pocsag_data", "value": data}) + + def write_backoff_message(self, reason): + self.send({"type": "backoff", "reason": reason}) + + def write_js8_message(self, frame: Js8Frame, freq: int): + self.send( + { + "type": "js8_message", + "value": { + "msg": str(frame), + "timestamp": frame.timestamp, + "db": frame.db, + "dt": frame.dt, + "freq": freq + frame.freq, + "thread_type": frame.thread_type, + "mode": frame.mode, + }, + } + ) + + def write_modes(self, modes): + def to_json(m): + res = { + "modulation": m.modulation, + "name": m.name, + "type": "digimode" if isinstance(m, DigitalMode) else "analog", + "requirements": m.requirements, + "squelch": m.squelch, + } + if m.bandpass is not None: + res["bandpass"] = {"low_cut": m.bandpass.low_cut, "high_cut": m.bandpass.high_cut} + if isinstance(m, DigitalMode): + res["underlying"] = m.underlying + return res + + self.send({"type": "modes", "value": [to_json(m) for m in modes]}) + + +class MapConnection(OpenWebRxClient): + def __init__(self, conn): + super().__init__(conn) + + pm = Config.get() + filtered_config = pm.filter( + "google_maps_api_key", + "receiver_gps", + "map_position_retention_time", + "receiver_name", + ) + filtered_config.wire(self.write_config) + + self.write_config(filtered_config.__dict__()) + + Map.getSharedInstance().addClient(self) + + def handleTextMessage(self, conn, message): + pass + + def close(self): + Map.getSharedInstance().removeClient(self) + super().close() + + def write_config(self, cfg): + self.send({"type": "config", "value": cfg}) + + def write_update(self, update): + self.mp_send({"type": "update", "value": update}) + + +class HandshakeMessageHandler(Handler): + """ + This handler receives text messages, but will only respond to the second handshake string. + As soon as a valid handshake is received, the handler replaces itself with the corresponding handler type. + """ + def handleTextMessage(self, conn, message): + if message[:16] == "SERVER DE CLIENT": + meta = message[17:].split(" ") + handshake = {v[0]: "=".join(v[1:]) for v in map(lambda x: x.split("="), meta)} + + logger.debug("client connection initialized") + + client = None + if "type" in handshake: + if handshake["type"] == "receiver": + client = OpenWebRxReceiverClient(conn) + elif handshake["type"] == "map": + client = MapConnection(conn) + else: + logger.warning("invalid connection type: %s", handshake["type"]) + + if client is not None: + logger.debug("handshake complete, handing off to %s", type(client).__name__) + # hand off all further communication to the correspondig connection + conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version=openwebrx_version)) + conn.setMessageHandler(client) + else: + logger.warning('invalid handshake received') + else: + logger.warning("not answering client request since handshake is not complete") + + def handleBinaryMessage(self, conn, data): + pass + + def handleClose(self): + pass diff --git a/openwebrx/owrx/controllers/__init__.py b/openwebrx/owrx/controllers/__init__.py new file mode 100644 index 0000000..bb929ce --- /dev/null +++ b/openwebrx/owrx/controllers/__init__.py @@ -0,0 +1,60 @@ +from datetime import datetime, timezone + + +class BodySizeError(Exception): + pass + + +class Controller(object): + def __init__(self, handler, request, options): + self.handler = handler + self.request = request + self.options = options + self.responseCookies = None + + def send_response( + self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None, headers=None + ): + self.handler.send_response(code) + if headers is None: + headers = {} + if content_type is not None: + headers["Content-Type"] = content_type + if last_modified is not None: + headers["Last-Modified"] = last_modified.astimezone(tz=timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT") + if max_age is not None: + headers["Cache-Control"] = "max-age={0}".format(max_age) + for key, value in headers.items(): + self.handler.send_header(key, value) + if self.responseCookies is not None: + self.handler.send_header("Set-Cookie", self.responseCookies.output(header="")) + self.handler.end_headers() + if type(content) == str: + content = content.encode() + while len(content): + w = self.handler.wfile.write(content) + content = content[w:] + + def send_redirect(self, location, code=303): + self.handler.send_response(code) + if self.responseCookies is not None: + self.handler.send_header("Set-Cookie", self.responseCookies.output(header="")) + self.handler.send_header("Location", location) + self.handler.end_headers() + + def set_response_cookies(self, cookies): + self.responseCookies = cookies + + def get_body(self, max_size=None): + if "Content-Length" not in self.handler.headers: + return None + length = int(self.handler.headers["Content-Length"]) + if max_size is not None and length > max_size: + raise BodySizeError("HTTP body exceeds maximum allowed size") + return self.handler.rfile.read(length) + + def handle_request(self): + action = "indexAction" + if "action" in self.options: + action = self.options["action"] + getattr(self, action)() diff --git a/openwebrx/owrx/controllers/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..8144428 Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/admin.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/admin.cpython-37.pyc new file mode 100644 index 0000000..0dfb063 Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/admin.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/api.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/api.cpython-37.pyc new file mode 100644 index 0000000..f412756 Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/api.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/assets.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/assets.cpython-37.pyc new file mode 100644 index 0000000..096d91f Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/assets.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/feature.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/feature.cpython-37.pyc new file mode 100644 index 0000000..9e52b27 Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/feature.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/imageupload.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/imageupload.cpython-37.pyc new file mode 100644 index 0000000..cb5d244 Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/imageupload.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/metrics.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/metrics.cpython-37.pyc new file mode 100644 index 0000000..8eea599 Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/metrics.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/profile.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/profile.cpython-37.pyc new file mode 100644 index 0000000..57003f6 Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/profile.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/receiverid.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/receiverid.cpython-37.pyc new file mode 100644 index 0000000..4526638 Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/receiverid.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/robots.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/robots.cpython-37.pyc new file mode 100644 index 0000000..1439754 Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/robots.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/session.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/session.cpython-37.pyc new file mode 100644 index 0000000..2c9eb2f Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/session.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/status.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/status.cpython-37.pyc new file mode 100644 index 0000000..2cda0a7 Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/status.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/template.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/template.cpython-37.pyc new file mode 100644 index 0000000..85322ba Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/template.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/__pycache__/websocket.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/websocket.cpython-37.pyc new file mode 100644 index 0000000..7fb0a1a Binary files /dev/null and b/openwebrx/owrx/controllers/__pycache__/websocket.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/admin.py b/openwebrx/owrx/controllers/admin.py new file mode 100644 index 0000000..803eeb8 --- /dev/null +++ b/openwebrx/owrx/controllers/admin.py @@ -0,0 +1,56 @@ +from owrx.controllers.session import SessionStorage +from owrx.users import UserList +from urllib import parse +from http.cookies import SimpleCookie + +import logging + +logger = logging.getLogger(__name__) + + +class Authentication(object): + def getUser(self, request): + if "owrx-session" not in request.cookies: + return None + session_id = request.cookies["owrx-session"].value + storage = SessionStorage.getSharedInstance() + session = storage.getSession(session_id) + if session is None: + return None + if "user" not in session: + return None + userList = UserList.getSharedInstance() + user = None + try: + user = userList[session["user"]] + storage.prolongSession(session_id) + except KeyError: + pass + return user + + +class AuthorizationMixin(object): + def __init__(self, handler, request, options): + self.authentication = Authentication() + self.user = self.authentication.getUser(request) + super().__init__(handler, request, options) + + def isAuthorized(self): + return self.user is not None and self.user.is_enabled() and not self.user.must_change_password + + def handle_request(self): + if self.isAuthorized(): + super().handle_request() + else: + cookie = SimpleCookie() + cookie["owrx-session"] = "" + cookie["owrx-session"]["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT" + self.set_response_cookies(cookie) + if ( + "x-requested-with" in self.request.headers + and self.request.headers["x-requested-with"] == "XMLHttpRequest" + ): + self.send_response("{}", code=403) + else: + target = "{}login?{}".format(self.get_document_root(), parse.urlencode({"ref": self.request.path[1:]})) + self.send_redirect(target) diff --git a/openwebrx/owrx/controllers/api.py b/openwebrx/owrx/controllers/api.py new file mode 100644 index 0000000..4e7a966 --- /dev/null +++ b/openwebrx/owrx/controllers/api.py @@ -0,0 +1,9 @@ +from . import Controller +from owrx.feature import FeatureDetector +import json + + +class ApiController(Controller): + def indexAction(self): + data = json.dumps(FeatureDetector().feature_report()) + self.send_response(data, content_type="application/json") diff --git a/openwebrx/owrx/controllers/assets.py b/openwebrx/owrx/controllers/assets.py new file mode 100644 index 0000000..ca3e7f9 --- /dev/null +++ b/openwebrx/owrx/controllers/assets.py @@ -0,0 +1,191 @@ +from . import Controller +from owrx.config.core import CoreConfig +from datetime import datetime, timezone +import mimetypes +import os +import pkg_resources +from abc import ABCMeta, abstractmethod +import gzip + +import logging + +logger = logging.getLogger(__name__) + + +class GzipMixin(object): + def send_response(self, content, code=200, headers=None, content_type="text/html", *args, **kwargs): + if self.zipable(content_type) and "accept-encoding" in self.request.headers: + accepted = [s.strip().lower() for s in self.request.headers["accept-encoding"].split(",")] + if "gzip" in accepted: + if type(content) == str: + content = content.encode() + content = self.gzip(content) + if headers is None: + headers = {} + headers["Content-Encoding"] = "gzip" + super().send_response(content, code, headers=headers, content_type=content_type, *args, **kwargs) + + def zipable(self, content_type): + types = ["application/javascript", "text/css", "text/html", "image/svg+xml"] + return content_type in types + + def gzip(self, content): + return gzip.compress(content) + + +class ModificationAwareController(Controller, metaclass=ABCMeta): + @abstractmethod + def getModified(self, file): + pass + + def wasModified(self, file): + try: + modified = self.getModified(file).replace(microsecond=0) + + if modified is not None and "If-Modified-Since" in self.handler.headers: + client_modified = datetime.strptime( + self.handler.headers["If-Modified-Since"], "%a, %d %b %Y %H:%M:%S %Z" + ).replace(tzinfo=timezone.utc) + if modified <= client_modified: + return False + except FileNotFoundError: + pass + + return True + + +class AssetsController(GzipMixin, ModificationAwareController, metaclass=ABCMeta): + def getModified(self, file): + return datetime.fromtimestamp(os.path.getmtime(self.getFilePath(file)), timezone.utc) + + def openFile(self, file): + return open(self.getFilePath(file), "rb") + + @abstractmethod + def getFilePath(self, file): + pass + + def serve_file(self, file, content_type=None): + try: + modified = self.getModified(file) + + if not self.wasModified(file): + self.send_response("", code=304) + return + + f = self.openFile(file) + data = f.read() + f.close() + + if content_type is None: + (content_type, encoding) = mimetypes.guess_type(self.getFilePath(file)) + self.send_response(data, content_type=content_type, last_modified=modified, max_age=3600) + except FileNotFoundError: + self.send_response("file not found", code=404) + + def indexAction(self): + filename = self.request.matches.group(1) + self.serve_file(filename) + + +class OwrxAssetsController(AssetsController): + def getFilePath(self, file): + mappedFiles = { + "gfx/openwebrx-avatar.png": "receiver_avatar", + "gfx/openwebrx-top-photo.jpg": "receiver_top_photo", + } + if file in mappedFiles and ("mapped" not in self.request.query or self.request.query["mapped"][0] != "false"): + config = CoreConfig() + for ext in ["png", "jpg", "webp"]: + user_file = "{}/{}.{}".format(config.get_data_directory(), mappedFiles[file], ext) + if os.path.exists(user_file) and os.path.isfile(user_file): + return user_file + return pkg_resources.resource_filename("htdocs", file) + + +class AprsSymbolsController(AssetsController): + def __init__(self, handler, request, options): + path = CoreConfig().get_aprs_symbols_path() + if not path.endswith("/"): + path += "/" + self.path = path + super().__init__(handler, request, options) + + def getFilePath(self, file): + return self.path + file + + +class CompiledAssetsController(GzipMixin, ModificationAwareController): + profiles = { + "receiver.js": [ + "lib/chroma.min.js", + "openwebrx.js", + "lib/jquery-3.2.1.min.js", + "lib/jquery.nanoscroller.min.js", + "lib/Header.js", + "lib/Demodulator.js", + "lib/DemodulatorPanel.js", + "lib/BookmarkLocalStorage.js", + "lib/BookmarkBar.js", + "lib/BookmarkDialog.js", + "lib/AudioEngine.js", + "lib/ProgressBar.js", + "lib/Measurement.js", + "lib/FrequencyDisplay.js", + "lib/MessagePanel.js", + "lib/Js8Threads.js", + "lib/Modes.js", + "lib/MetaPanel.js", + ], + "map.js": [ + "lib/jquery-3.2.1.min.js", + "lib/chroma.min.js", + "lib/Header.js", + "map.js", + ], + "settings.js": [ + "lib/jquery-3.2.1.min.js", + "lib/bootstrap.bundle.min.js", + "lib/location-picker.min.js", + "lib/Header.js", + "lib/settings/MapInput.js", + "lib/settings/ImageUpload.js", + "lib/BookmarkLocalStorage.js", + "lib/settings/BookmarkTable.js", + "lib/settings/WsjtDecodingDepthsInput.js", + "lib/settings/WaterfallDropdown.js", + "lib/settings/GainInput.js", + "lib/settings/OptionalSection.js", + "lib/settings/SchedulerInput.js", + "lib/settings/ExponentialInput.js", + "settings.js", + ], + } + + def indexAction(self): + profileName = self.request.matches.group(1) + if profileName not in CompiledAssetsController.profiles: + self.send_response("profile not found", code=404) + return + + files = CompiledAssetsController.profiles[profileName] + files = [pkg_resources.resource_filename("htdocs", f) for f in files] + + modified = self.getModified(files) + + if not self.wasModified(files): + self.send_response("", code=304) + return + + contents = [self.getContents(f) for f in files] + + (content_type, encoding) = mimetypes.guess_type(profileName) + self.send_response("\n".join(contents), content_type=content_type, last_modified=modified, max_age=3600) + + def getContents(self, file): + with open(file) as f: + return f.read() + + def getModified(self, files): + modified = [os.path.getmtime(f) for f in files] + return datetime.fromtimestamp(max(*modified), timezone.utc) diff --git a/openwebrx/owrx/controllers/feature.py b/openwebrx/owrx/controllers/feature.py new file mode 100644 index 0000000..06e262d --- /dev/null +++ b/openwebrx/owrx/controllers/feature.py @@ -0,0 +1,11 @@ +from owrx.controllers.template import WebpageController +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbMixin +from owrx.controllers.settings import SettingsBreadcrumb + + +class FeatureController(BreadcrumbMixin, WebpageController): + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("Feature report", "features")) + + def indexAction(self): + self.serve_template("features.html", **self.template_variables()) diff --git a/openwebrx/owrx/controllers/imageupload.py b/openwebrx/owrx/controllers/imageupload.py new file mode 100644 index 0000000..7686659 --- /dev/null +++ b/openwebrx/owrx/controllers/imageupload.py @@ -0,0 +1,79 @@ +from owrx.controllers import BodySizeError +from owrx.controllers.assets import AssetsController +from owrx.controllers.admin import AuthorizationMixin +from owrx.config.core import CoreConfig +from owrx.form.input.gfx import AvatarInput, TopPhotoInput +import uuid +import json + + +class ImageUploadController(AuthorizationMixin, AssetsController): + # max upload filesizes + max_sizes = { + # not the best idea to instantiate inputs, but i didn't want to duplicate the sizes here + "receiver_avatar": AvatarInput("id", "label").getMaxSize(), + "receiver_top_photo": TopPhotoInput("id", "label").getMaxSize(), + } + + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.file = request.query["file"][0] if "file" in request.query else None + + def getFilePath(self, file=None): + if self.file is None: + raise FileNotFoundError("missing filename") + return "{tmp}/{file}".format( + tmp=CoreConfig().get_temporary_directory(), + file=self.file + ) + + def indexAction(self): + self.serve_file(None) + + def _is_png(self, contents): + return contents[0:8] == bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + + def _is_jpg(self, contents): + return contents[0:3] == bytes([0xFF, 0xD8, 0xFF]) + + def _is_webp(self, contents): + return contents[0:4] == bytes([0x52, 0x49, 0x46, 0x46]) and contents[8:12] == bytes([0x57, 0x45, 0x42, 0x50]) + + def processImage(self): + if "id" not in self.request.query: + self.send_json_response({"error": "missing id"}, code=400) + return + file_id = self.request.query["id"][0] + + if file_id not in ImageUploadController.max_sizes: + self.send_json_response({"error": "unexpected image id"}, code=400) + return + + try: + contents = self.get_body(ImageUploadController.max_sizes[file_id]) + except BodySizeError: + self.send_json_response({"error": "file size too large"}, code=400) + return + + filetype = None + if self._is_png(contents): + filetype = "png" + elif self._is_jpg(contents): + filetype = "jpg" + elif self._is_webp(contents): + filetype = "webp" + if filetype is None: + self.send_json_response({"error": "unsupported file type"}, code=400) + return + + self.file = "{id}-{uuid}.{ext}".format( + id=file_id, + uuid=uuid.uuid4().hex, + ext=filetype, + ) + with open(self.getFilePath(), "wb") as f: + f.write(contents) + self.send_json_response({"file": self.file}, code=200) + + def send_json_response(self, obj, code): + self.send_response(json.dumps(obj), code=code, content_type="application/json") diff --git a/openwebrx/owrx/controllers/metrics.py b/openwebrx/owrx/controllers/metrics.py new file mode 100644 index 0000000..4d41a2a --- /dev/null +++ b/openwebrx/owrx/controllers/metrics.py @@ -0,0 +1,30 @@ +from . import Controller +from owrx.metrics import CounterMetric, DirectMetric, Metrics +import json + + +class MetricsController(Controller): + def indexAction(self): + data = json.dumps(Metrics.getSharedInstance().getHierarchicalMetrics()) + self.send_response(data, content_type="application/json") + + def prometheusAction(self): + metrics = Metrics.getSharedInstance().getFlatMetrics() + + def prometheusFormat(key, metric): + value = metric.getValue() + if isinstance(metric, CounterMetric): + key += "_total" + value = value["count"] + elif isinstance(metric, DirectMetric): + pass + else: + raise ValueError("Unexpected metric type for metric {}".format(repr(metric))) + + return "{key} {value}".format(key=key.replace(".", "_"), value=value) + + data = ["# https://prometheus.io/docs/instrumenting/exposition_formats/"] + [ + prometheusFormat(k, v) for k, v in metrics.items() + ] + + self.send_response("\n".join(data), content_type="text/plain; version=0.0.4") diff --git a/openwebrx/owrx/controllers/profile.py b/openwebrx/owrx/controllers/profile.py new file mode 100644 index 0000000..6fd3aaf --- /dev/null +++ b/openwebrx/owrx/controllers/profile.py @@ -0,0 +1,24 @@ +from owrx.controllers.template import WebpageController +from owrx.controllers.admin import AuthorizationMixin +from owrx.users import UserList, DefaultPasswordClass +from urllib.parse import parse_qs + + +class ProfileController(AuthorizationMixin, WebpageController): + def isAuthorized(self): + return self.user is not None and self.user.is_enabled() and self.user.must_change_password + + def indexAction(self): + self.serve_template("pwchange.html", **self.template_variables()) + + def processPwChange(self): + data = parse_qs(self.get_body().decode("utf-8")) + data = {k: v[0] for k, v in data.items()} + userlist = UserList.getSharedInstance() + if "password" in data and "confirm" in data and data["password"] == data["confirm"]: + self.user.setPassword(DefaultPasswordClass(data["password"]), must_change_password=False) + userlist.store() + target = self.request.query["ref"][0] if "ref" in self.request.query else "/settings" + else: + target = "/pwchange" + self.send_redirect(target) diff --git a/openwebrx/owrx/controllers/receiverid.py b/openwebrx/owrx/controllers/receiverid.py new file mode 100644 index 0000000..10c7361 --- /dev/null +++ b/openwebrx/owrx/controllers/receiverid.py @@ -0,0 +1,26 @@ +from owrx.controllers import Controller +from owrx.receiverid import ReceiverId +from datetime import datetime + + +class ReceiverIdController(Controller): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.authHeader = None + + def send_response( + self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None, headers=None + ): + if self.authHeader is not None: + if headers is None: + headers = {} + headers["Authorization"] = self.authHeader + super().send_response( + content, code=code, content_type=content_type, last_modified=last_modified, max_age=max_age, headers=headers + ) + pass + + def handle_request(self): + if "Authorization" in self.request.headers: + self.authHeader = ReceiverId.getResponseHeader(self.request.headers["Authorization"]) + super().handle_request() diff --git a/openwebrx/owrx/controllers/robots.py b/openwebrx/owrx/controllers/robots.py new file mode 100644 index 0000000..3d00a1e --- /dev/null +++ b/openwebrx/owrx/controllers/robots.py @@ -0,0 +1,16 @@ +from owrx.controllers import Controller + + +class RobotsController(Controller): + def indexAction(self): + # search engines should not be crawling internal / API routes + self.send_response( + """User-agent: * +Disallow: /login +Disallow: /logout +Disallow: /pwchange +Disallow: /settings +Disallow: /imageupload +""", + content_type="text/plain", + ) diff --git a/openwebrx/owrx/controllers/session.py b/openwebrx/owrx/controllers/session.py new file mode 100644 index 0000000..6807a91 --- /dev/null +++ b/openwebrx/owrx/controllers/session.py @@ -0,0 +1,79 @@ +from owrx.controllers.template import WebpageController +from urllib.parse import parse_qs, urlencode +from uuid import uuid4 +from http.cookies import SimpleCookie +from owrx.users import UserList +from datetime import datetime, timedelta + +import logging + +logger = logging.getLogger(__name__) + + +class SessionStorage(object): + sharedInstance = None + sessionLifetime = timedelta(hours=6) + + @staticmethod + def getSharedInstance(): + if SessionStorage.sharedInstance is None: + SessionStorage.sharedInstance = SessionStorage() + return SessionStorage.sharedInstance + + def __init__(self): + self.sessions = {} + + def generateKey(self): + return str(uuid4()) + + def startSession(self, data): + key = self.generateKey() + self.updateSession(key, data) + return key + + def getSession(self, key): + if key not in self.sessions: + return None + expires, data = self.sessions[key] + if expires < datetime.utcnow(): + del self.sessions[key] + return None + return data + + def updateSession(self, key, data): + expires = datetime.utcnow() + SessionStorage.sessionLifetime + self.sessions[key] = expires, data + + def prolongSession(self, key): + data = self.getSession(key) + if data is None: + raise KeyError("Invalid session key") + self.updateSession(key, data) + + +class SessionController(WebpageController): + def loginAction(self): + self.serve_template("login.html", **self.template_variables()) + + def processLoginAction(self): + data = parse_qs(self.get_body().decode("utf-8")) + data = {k: v[0] for k, v in data.items()} + userlist = UserList.getSharedInstance() + if "user" in data and "password" in data: + if data["user"] in userlist: + user = userlist[data["user"]] + if user.is_enabled() and user.password.is_valid(data["password"]): + key = SessionStorage.getSharedInstance().startSession({"user": user.name}) + cookie = SimpleCookie() + cookie["owrx-session"] = key + target = self.request.query["ref"][0] if "ref" in self.request.query else "/settings" + if user.must_change_password: + target = "/pwchange?{0}".format(urlencode({"ref": target})) + self.set_response_cookies(cookie) + self.send_redirect(target) + return + target = "?{}".format(urlencode({"ref": self.request.query["ref"][0]})) if "ref" in self.request.query else "" + self.send_redirect(self.request.path + target) + + def logoutAction(self): + self.send_redirect("logout happening here") diff --git a/openwebrx/owrx/controllers/settings/__init__.py b/openwebrx/owrx/controllers/settings/__init__.py new file mode 100644 index 0000000..8ad8a4e --- /dev/null +++ b/openwebrx/owrx/controllers/settings/__init__.py @@ -0,0 +1,147 @@ +from owrx.config import Config +from owrx.controllers.admin import AuthorizationMixin +from owrx.controllers.template import WebpageController +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbMixin +from abc import ABCMeta, abstractmethod +from urllib.parse import parse_qs + +import logging + +logger = logging.getLogger(__name__) + + +class SettingsController(AuthorizationMixin, WebpageController): + def indexAction(self): + self.serve_template("settings.html", **self.template_variables()) + + +class SettingsFormController(AuthorizationMixin, BreadcrumbMixin, WebpageController, metaclass=ABCMeta): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.errors = {} + self.globalError = None + + @abstractmethod + def getSections(self): + pass + + @abstractmethod + def getTitle(self): + pass + + def getData(self): + return Config.get() + + def getErrors(self): + return self.errors + + def render_sections(self): + sections = "".join(section.render(self.getData(), self.getErrors()) for section in self.getSections()) + buttons = self.render_buttons() + return """ +
    + {sections} +
    + {buttons} +
    +
    + """.format( + sections=sections, + buttons=buttons, + ) + + def render_buttons(self): + return """ + + """ + + def indexAction(self): + self.serve_template("settings/general.html", **self.template_variables()) + + def template_variables(self): + variables = super().template_variables() + variables["content"] = self.render_sections() + variables["title"] = self.getTitle() + variables["modal"] = self.buildModal() + variables["error"] = self.renderGlobalError() + return variables + + def parseFormData(self): + data = parse_qs(self.get_body().decode("utf-8"), keep_blank_values=True) + result = {} + errors = [] + for section in self.getSections(): + section_data, section_errors = section.parse(data) + result.update(section_data) + errors += section_errors + return result, errors + + def getSuccessfulRedirect(self): + return self.get_document_root() + self.request.path[1:] + + def _mergeErrors(self, errors): + result = {} + for e in errors: + if e.getKey() not in result: + result[e.getKey()] = [] + result[e.getKey()].append(e.getMessage()) + return result + + def processFormData(self): + data = None + errors = None + try: + data, errors = self.parseFormData() + except Exception as e: + logger.exception("Error while parsing form data") + self.globalError = str(e) + return self.indexAction() + + if errors: + self.errors = self._mergeErrors(errors) + return self.indexAction() + try: + self.processData(data) + self.store() + self.send_redirect(self.getSuccessfulRedirect()) + except Exception as e: + logger.exception("Error while processing form data") + self.globalError = str(e) + return self.indexAction() + + def processData(self, data): + config = self.getData() + for k, v in data.items(): + if v is None: + if k in config: + del config[k] + else: + config[k] = v + + def store(self): + Config.get().store() + + def buildModal(self): + return "" + + def renderGlobalError(self): + if self.globalError is None: + return "" + + return """ +
    +
    Error
    +
    +
    Your settings could not be saved due to an error:
    +
    {error}
    +
    +
    + """.format( + error=self.globalError + ) + + +class SettingsBreadcrumb(Breadcrumb): + def __init__(self): + super().__init__([]) + self.append(BreadcrumbItem("Settings", "settings")) diff --git a/openwebrx/owrx/controllers/settings/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..e031fcd Binary files /dev/null and b/openwebrx/owrx/controllers/settings/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/settings/__pycache__/backgrounddecoding.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/backgrounddecoding.cpython-37.pyc new file mode 100644 index 0000000..5c541e8 Binary files /dev/null and b/openwebrx/owrx/controllers/settings/__pycache__/backgrounddecoding.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/settings/__pycache__/bookmarks.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/bookmarks.cpython-37.pyc new file mode 100644 index 0000000..0e81b14 Binary files /dev/null and b/openwebrx/owrx/controllers/settings/__pycache__/bookmarks.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/settings/__pycache__/decoding.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/decoding.cpython-37.pyc new file mode 100644 index 0000000..1508ad6 Binary files /dev/null and b/openwebrx/owrx/controllers/settings/__pycache__/decoding.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/settings/__pycache__/general.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/general.cpython-37.pyc new file mode 100644 index 0000000..7feab09 Binary files /dev/null and b/openwebrx/owrx/controllers/settings/__pycache__/general.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/settings/__pycache__/reporting.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/reporting.cpython-37.pyc new file mode 100644 index 0000000..0e023d5 Binary files /dev/null and b/openwebrx/owrx/controllers/settings/__pycache__/reporting.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/settings/__pycache__/sdr.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/sdr.cpython-37.pyc new file mode 100644 index 0000000..fb4ae13 Binary files /dev/null and b/openwebrx/owrx/controllers/settings/__pycache__/sdr.cpython-37.pyc differ diff --git a/openwebrx/owrx/controllers/settings/backgrounddecoding.py b/openwebrx/owrx/controllers/settings/backgrounddecoding.py new file mode 100644 index 0000000..902c37f --- /dev/null +++ b/openwebrx/owrx/controllers/settings/backgrounddecoding.py @@ -0,0 +1,25 @@ +from owrx.controllers.settings import SettingsFormController +from owrx.form.section import Section +from owrx.form.input import CheckboxInput, ServicesCheckboxInput +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem +from owrx.controllers.settings import SettingsBreadcrumb + + +class BackgroundDecodingController(SettingsFormController): + def getTitle(self): + return "Background decoding" + + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("Background decoding", "settings/backgrounddecoding")) + + def getSections(self): + return [ + Section( + "Background decoding", + CheckboxInput( + "services_enabled", + "Enable background decoding services", + ), + ServicesCheckboxInput("services_decoders", "Enabled services"), + ), + ] diff --git a/openwebrx/owrx/controllers/settings/bookmarks.py b/openwebrx/owrx/controllers/settings/bookmarks.py new file mode 100644 index 0000000..2704bb6 --- /dev/null +++ b/openwebrx/owrx/controllers/settings/bookmarks.py @@ -0,0 +1,148 @@ +from owrx.controllers.template import WebpageController +from owrx.controllers.admin import AuthorizationMixin +from owrx.controllers.settings import SettingsBreadcrumb +from owrx.bookmarks import Bookmark, Bookmarks +from owrx.modes import Modes +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbMixin +import json +import math + +import logging + +logger = logging.getLogger(__name__) + + +class BookmarksController(AuthorizationMixin, BreadcrumbMixin, WebpageController): + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("Bookmark editor", "settings/bookmarks")) + + def template_variables(self): + variables = super().template_variables() + variables["bookmarks"] = self.render_table() + return variables + + def render_table(self): + bookmarks = Bookmarks.getSharedInstance().getBookmarks() + emptyText = """ + + No bookmarks in storage. You can add new bookmarks using the buttons below. + + """ + + return """ + + + + + + + + {bookmarks} +
    NameFrequencyModulationActions
    + """.format( + bookmarks="".join(self.render_bookmark(b) for b in bookmarks) if bookmarks else emptyText, + modes=json.dumps({m.modulation: m.name for m in Modes.getAvailableModes()}), + ) + + def render_bookmark(self, bookmark: Bookmark): + def render_frequency(freq): + suffixes = { + 0: "", + 3: "k", + 6: "M", + 9: "G", + 12: "T", + } + exp = 0 + if freq > 0: + exp = int(math.log10(freq) / 3) * 3 + num = freq + suffix = "" + if exp in suffixes: + num = freq / 10 ** exp + suffix = suffixes[exp] + return "{num:g} {suffix}Hz".format(num=num, suffix=suffix) + + mode = Modes.findByModulation(bookmark.getModulation()) + return """ + + {name} + {rendered_frequency} + {modulation_name} + + + + + """.format( + id=id(bookmark), + name=bookmark.getName(), + # TODO render frequency in si units + frequency=bookmark.getFrequency(), + rendered_frequency=render_frequency(bookmark.getFrequency()), + modulation=bookmark.getModulation() if mode is None else mode.modulation, + modulation_name=bookmark.getModulation() if mode is None else mode.name, + ) + + def _findBookmark(self, bookmark_id): + bookmarks = Bookmarks.getSharedInstance() + try: + return next(b for b in bookmarks.getBookmarks() if id(b) == bookmark_id) + except StopIteration: + return None + + def update(self): + bookmark_id = int(self.request.matches.group(1)) + bookmark = self._findBookmark(bookmark_id) + if bookmark is None: + self.send_response("{}", content_type="application/json", code=404) + return + try: + data = json.loads(self.get_body().decode("utf-8")) + for key in ["name", "frequency", "modulation"]: + if key in data: + value = data[key] + if key == "frequency": + value = int(value) + setattr(bookmark, key, value) + Bookmarks.getSharedInstance().store() + # TODO this should not be called explicitly... bookmarks don't have any event capability right now, though + Bookmarks.getSharedInstance().notifySubscriptions(bookmark) + self.send_response("{}", content_type="application/json", code=200) + except json.JSONDecodeError: + self.send_response("{}", content_type="application/json", code=400) + + def new(self): + bookmarks = Bookmarks.getSharedInstance() + + def create(bookmark_data): + # sanitize + data = { + "name": bookmark_data["name"], + "frequency": int(bookmark_data["frequency"]), + "modulation": bookmark_data["modulation"], + } + bookmark = Bookmark(data) + bookmarks.addBookmark(bookmark) + return {"bookmark_id": id(bookmark)} + + try: + data = json.loads(self.get_body().decode("utf-8")) + result = [create(b) for b in data] + bookmarks.store() + self.send_response(json.dumps(result), content_type="application/json", code=200) + except json.JSONDecodeError: + self.send_response("{}", content_type="application/json", code=400) + + def delete(self): + bookmark_id = int(self.request.matches.group(1)) + bookmark = self._findBookmark(bookmark_id) + if bookmark is None: + self.send_response("{}", content_type="application/json", code=404) + return + bookmarks = Bookmarks.getSharedInstance() + bookmarks.removeBookmark(bookmark) + bookmarks.store() + self.send_response("{}", content_type="application/json", code=200) + + def indexAction(self): + self.serve_template("settings/bookmarks.html", **self.template_variables()) diff --git a/openwebrx/owrx/controllers/settings/decoding.py b/openwebrx/owrx/controllers/settings/decoding.py new file mode 100644 index 0000000..fb0e542 --- /dev/null +++ b/openwebrx/owrx/controllers/settings/decoding.py @@ -0,0 +1,91 @@ +from owrx.controllers.settings import SettingsFormController, SettingsBreadcrumb +from owrx.form.section import Section +from owrx.form.input import CheckboxInput, NumberInput, DropdownInput, Js8ProfileCheckboxInput, MultiCheckboxInput, Option, TextInput +from owrx.form.input.wfm import WfmTauValues +from owrx.form.input.wsjt import Q65ModeMatrix, WsjtDecodingDepthsInput +from owrx.form.input.converter import OptionalConverter +from owrx.wsjt import Fst4Profile, Fst4wProfile +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem + + +class DecodingSettingsController(SettingsFormController): + def getTitle(self): + return "Demodulation and decoding" + + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("Demodulation and decoding", "settings/decoding")) + + def getSections(self): + return [ + Section( + "Demodulator settings", + NumberInput( + "squelch_auto_margin", + "Auto-Squelch threshold", + infotext="Offset to be added to the current signal level when using the auto-squelch", + append="dB", + ), + DropdownInput( + "wfm_deemphasis_tau", + "Tau setting for WFM (broadcast FM) deemphasis", + WfmTauValues, + infotext='See this Wikipedia article for more information', + ), + ), + Section( + "Digital voice", + TextInput( + "digital_voice_codecserver", + "Codecserver address", + infotext="Address of a remote codecserver instance (name[:port]). Leave empty to use local" + + " codecserver", + converter=OptionalConverter(), + ), + CheckboxInput( + "digital_voice_dmr_id_lookup", + 'Enable lookup of DMR ids in the ' + + "radioid database to show callsigns and names", + ), + CheckboxInput( + "digital_voice_nxdn_id_lookup", + 'Enable lookup of NXDN ids in the ' + + "radioid database to show callsigns and names", + ), + ), + Section( + "Digimodes", + NumberInput("digimodes_fft_size", "Digimodes FFT size", append="bins"), + ), + Section( + "Decoding settings", + NumberInput("decoding_queue_workers", "Number of decoding workers"), + NumberInput("decoding_queue_length", "Maximum length of decoding job queue"), + NumberInput( + "wsjt_decoding_depth", + "Default WSJT decoding depth", + infotext="A higher decoding depth will allow more results, but will also consume more cpu", + ), + WsjtDecodingDepthsInput( + "wsjt_decoding_depths", + "Individual decoding depths", + ), + NumberInput( + "js8_decoding_depth", + "Js8Call decoding depth", + infotext="A higher decoding depth will allow more results, but will also consume more cpu", + ), + Js8ProfileCheckboxInput("js8_enabled_profiles", "Js8Call enabled modes"), + MultiCheckboxInput( + "fst4_enabled_intervals", + "Enabled FST4 intervals", + [Option(v, "{}s".format(v)) for v in Fst4Profile.availableIntervals], + ), + MultiCheckboxInput( + "fst4w_enabled_intervals", + "Enabled FST4W intervals", + [Option(v, "{}s".format(v)) for v in Fst4wProfile.availableIntervals], + ), + Q65ModeMatrix("q65_enabled_combinations", "Enabled Q65 Mode combinations"), + ), + ] diff --git a/openwebrx/owrx/controllers/settings/general.py b/openwebrx/owrx/controllers/settings/general.py new file mode 100644 index 0000000..e12c101 --- /dev/null +++ b/openwebrx/owrx/controllers/settings/general.py @@ -0,0 +1,218 @@ +from owrx.controllers.settings import SettingsFormController +from owrx.form.section import Section +from owrx.config.core import CoreConfig +from owrx.form.input import ( + TextInput, + NumberInput, + FloatInput, + LocationInput, + TextAreaInput, + DropdownInput, + Option, +) +from owrx.form.input.converter import WaterfallColorsConverter, IntConverter +from owrx.form.input.receiverid import ReceiverKeysConverter +from owrx.form.input.gfx import AvatarInput, TopPhotoInput +from owrx.form.input.device import WaterfallLevelsInput, WaterfallAutoLevelsInput +from owrx.waterfall import WaterfallOptions +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem +from owrx.controllers.settings import SettingsBreadcrumb +import shutil +import os +import re +from glob import glob + +import logging + +logger = logging.getLogger(__name__) + + +class GeneralSettingsController(SettingsFormController): + def getTitle(self): + return "General Settings" + + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("General Settings", "settings/general")) + + def getSections(self): + return [ + Section( + "Receiver information", + TextInput("receiver_name", "Receiver name"), + TextInput("receiver_location", "Receiver location"), + NumberInput( + "receiver_asl", + "Receiver elevation", + append="meters above mean sea level", + ), + TextInput("receiver_admin", "Receiver admin"), + LocationInput("receiver_gps", "Receiver coordinates"), + TextInput("photo_title", "Photo title"), + TextAreaInput("photo_desc", "Photo description"), + ), + Section( + "Receiver images", + AvatarInput( + "receiver_avatar", + "Receiver Avatar", + infotext="For performance reasons, images are cached. " + + "It can take a few hours until they appear on the site.", + ), + TopPhotoInput( + "receiver_top_photo", + "Receiver Panorama", + infotext="For performance reasons, images are cached. " + + "It can take a few hours until they appear on the site.", + ), + ), + Section( + "Receiver limits", + NumberInput( + "max_clients", + "Maximum number of clients", + ), + ), + Section( + "Receiver listings", + TextAreaInput( + "receiver_keys", + "Receiver keys", + converter=ReceiverKeysConverter(), + infotext="Put the keys you receive on listing sites (e.g. " + + 'Receiverbook) here, one per line', + ), + ), + Section( + "Waterfall settings", + DropdownInput( + "waterfall_scheme", + "Waterfall color scheme", + options=WaterfallOptions, + ), + TextAreaInput( + "waterfall_colors", + "Custom waterfall colors", + infotext="Please provide 6-digit hexadecimal RGB colors in HTML notation (#RRGGBB)" + + " or HEX notation (0xRRGGBB), one per line", + converter=WaterfallColorsConverter(), + ), + NumberInput( + "fft_fps", + "FFT speed", + infotext="This setting specifies how many lines are being added to the waterfall per second. " + + "Higher values will give you a faster waterfall, but will also use more CPU.", + append="frames per second", + ), + NumberInput("fft_size", "FFT size", append="bins"), + FloatInput( + "fft_voverlap_factor", + "FFT vertical overlap factor", + infotext="If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the " + + "diagram.", + ), + WaterfallLevelsInput("waterfall_levels", "Waterfall levels"), + WaterfallAutoLevelsInput( + "waterfall_auto_levels", + "Automatic adjustment margins", + infotext="Specifies the upper and lower dynamic headroom that should be added when automatically " + + "adjusting waterfall colors", + ), + NumberInput( + "waterfall_auto_min_range", + "Automatic adjustment minimum range", + append="dB", + infotext="Minimum dynamic range the waterfall should cover after automatically adjusting " + + "waterfall colors", + ), + ), + Section( + "Compression", + DropdownInput( + "audio_compression", + "Audio compression", + options=[ + Option("adpcm", "ADPCM"), + Option("none", "None"), + ], + ), + DropdownInput( + "fft_compression", + "Waterfall compression", + options=[ + Option("adpcm", "ADPCM"), + Option("none", "None"), + ], + ), + ), + Section( + "Display settings", + DropdownInput( + "tuning_precision", + "Tuning precision", + options=[Option(str(i), "{} Hz".format(10 ** i)) for i in range(0, 6)], + converter=IntConverter(), + ), + ), + Section( + "Map settings", + TextInput( + "google_maps_api_key", + "Google Maps API key", + infotext="Google Maps requires an API key, check out " + + '' + + "their documentation on how to obtain one.", + ), + NumberInput( + "map_position_retention_time", + "Map retention time", + infotext="Specifies how log markers / grids will remain visible on the map", + append="s", + ), + ), + ] + + def remove_existing_image(self, image_id): + config = CoreConfig() + # remove all possible file extensions + for ext in ["png", "jpg", "webp"]: + try: + os.unlink("{}/{}.{}".format(config.get_data_directory(), image_id, ext)) + except FileNotFoundError: + pass + + def handle_image(self, data, image_id): + if image_id in data: + config = CoreConfig() + if data[image_id] == "restore": + self.remove_existing_image(image_id) + elif data[image_id]: + if not data[image_id].startswith(image_id): + logger.warning("invalid file name: %s", data[image_id]) + else: + # get file extension (at least 3 characters) + # should be all lowercase since they are set by the upload script + pattern = re.compile(".*\\.([a-z]{3,})$") + matches = pattern.match(data[image_id]) + if matches is None: + logger.warning("could not determine file extension for %s", image_id) + else: + self.remove_existing_image(image_id) + ext = matches.group(1) + data_file = "{}/{}.{}".format(config.get_data_directory(), image_id, ext) + temporary_file = "{}/{}".format(config.get_temporary_directory(), data[image_id]) + shutil.copy(temporary_file, data_file) + del data[image_id] + # remove any accumulated temporary files on save + for file in glob("{}/{}*".format(config.get_temporary_directory(), image_id)): + os.unlink(file) + + def processData(self, data): + # Image handling + for img in ["receiver_avatar", "receiver_top_photo"]: + self.handle_image(data, img) + # special handling for waterfall colors: custom colors only stay in config if custom color scheme is selected + if "waterfall_scheme" in data: + scheme = WaterfallOptions(data["waterfall_scheme"]) + if scheme is not WaterfallOptions.CUSTOM and "waterfall_colors" in data: + data["waterfall_colors"] = None + super().processData(data) diff --git a/openwebrx/owrx/controllers/settings/reporting.py b/openwebrx/owrx/controllers/settings/reporting.py new file mode 100644 index 0000000..09ee775 --- /dev/null +++ b/openwebrx/owrx/controllers/settings/reporting.py @@ -0,0 +1,93 @@ +from owrx.controllers.settings import SettingsFormController, SettingsBreadcrumb +from owrx.form.section import Section +from owrx.form.input.converter import OptionalConverter +from owrx.form.input.aprs import AprsBeaconSymbols, AprsAntennaDirections +from owrx.form.input import TextInput, CheckboxInput, DropdownInput, NumberInput +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem + + +class ReportingController(SettingsFormController): + def getTitle(self): + return "Spotting and reporting" + + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("Spotting and reporting", "settings/reporting")) + + def getSections(self): + return [ + Section( + "APRS-IS reporting", + CheckboxInput( + "aprs_igate_enabled", + "Send received APRS data to APRS-IS", + infotext="Due to limits of the APRS-IS network, reporting will only work for background decoders" + ), + TextInput( + "aprs_callsign", + "APRS callsign", + infotext="This callsign will be used to send data to the APRS-IS network", + ), + TextInput("aprs_igate_server", "APRS-IS server"), + TextInput("aprs_igate_password", "APRS-IS network password"), + CheckboxInput( + "aprs_igate_beacon", + "Send the receiver position to the APRS-IS network", + infotext="Please check that your receiver location is setup correctly before enabling the beacon", + ), + DropdownInput( + "aprs_igate_symbol", + "APRS beacon symbol", + AprsBeaconSymbols, + ), + TextInput( + "aprs_igate_comment", + "APRS beacon text", + infotext="This text will be sent as APRS comment along with your beacon", + converter=OptionalConverter(), + ), + NumberInput( + "aprs_igate_height", + "Antenna height", + infotext="Antenna height above average terrain (HAAT)", + append="m", + converter=OptionalConverter(), + ), + NumberInput( + "aprs_igate_gain", + "Antenna gain", + append="dBi", + converter=OptionalConverter(), + ), + DropdownInput("aprs_igate_dir", "Antenna direction", AprsAntennaDirections), + ), + Section( + "pskreporter settings", + CheckboxInput( + "pskreporter_enabled", + "Enable sending spots to pskreporter.info", + ), + TextInput( + "pskreporter_callsign", + "pskreporter callsign", + infotext="This callsign will be used to send spots to pskreporter.info", + ), + TextInput( + "pskreporter_antenna_information", + "Antenna information", + infotext="Antenna description to be sent along with spots to pskreporter", + converter=OptionalConverter(), + ), + ), + Section( + "WSPRnet settings", + CheckboxInput( + "wsprnet_enabled", + "Enable sending spots to wsprnet.org", + ), + TextInput( + "wsprnet_callsign", + "wsprnet callsign", + infotext="This callsign will be used to send spots to wsprnet.org", + ), + ), + ] diff --git a/openwebrx/owrx/controllers/settings/sdr.py b/openwebrx/owrx/controllers/settings/sdr.py new file mode 100644 index 0000000..40c6185 --- /dev/null +++ b/openwebrx/owrx/controllers/settings/sdr.py @@ -0,0 +1,433 @@ +from owrx.controllers.admin import AuthorizationMixin +from owrx.controllers.template import WebpageController +from owrx.controllers.settings import SettingsFormController +from owrx.source import SdrDeviceDescription, SdrDeviceDescriptionMissing, SdrClientClass +from owrx.config import Config +from owrx.connection import OpenWebRxReceiverClient +from owrx.controllers.settings import SettingsBreadcrumb +from owrx.form.section import Section +from urllib.parse import quote, unquote +from owrx.sdr import SdrService +from owrx.form.input import TextInput, DropdownInput, Option +from owrx.form.input.validator import RequiredValidator +from owrx.property import PropertyLayer +from owrx.breadcrumb import BreadcrumbMixin, Breadcrumb, BreadcrumbItem +from abc import ABCMeta, abstractmethod +from uuid import uuid4 + + +class SdrDeviceBreadcrumb(SettingsBreadcrumb): + def __init__(self): + super().__init__() + self.append(BreadcrumbItem("SDR device settings", "settings/sdr")) + + +class SdrDeviceListController(AuthorizationMixin, BreadcrumbMixin, WebpageController): + def template_variables(self): + variables = super().template_variables() + variables["content"] = self.render_devices() + variables["title"] = "SDR device settings" + variables["modal"] = "" + variables["error"] = "" + return variables + + def get_breadcrumb(self): + return SdrDeviceBreadcrumb() + + def render_devices(self): + def render_device(device_id, config): + sources = SdrService.getAllSources() + source = sources[device_id] if device_id in sources else None + + additional_info = "" + state_info = "Unknown" + + if source is not None: + profiles = source.getProfiles() + currentProfile = profiles[source.getProfileId()] + clients = {c: len(source.getClients(c)) for c in SdrClientClass} + clients = {c: v for c, v in clients.items() if v} + connections = len([c for c in source.getClients() if isinstance(c, OpenWebRxReceiverClient)]) + additional_info = """ +
    {num_profiles} profile(s)
    +
    Current profile: {current_profile}
    +
    Clients: {clients}
    +
    Connections: {connections}
    + """.format( + num_profiles=len(config["profiles"]), + current_profile=currentProfile["name"], + clients=", ".join("{cls}: {count}".format(cls=c.name, count=v) for c, v in clients.items()), + connections=connections, + ) + + state_info = ", ".join( + s + for s in [ + str(source.getState()), + None if source.isEnabled() else "Disabled", + "Failed" if source.isFailed() else None, + ] + if s is not None + ) + + return """ +
  • +
    +
    + +

    {device_name}

    +
    +
    State: {state}
    +
    +
    + {additional_info} +
    +
    +
  • + """.format( + device_name=config["name"] if config["name"] else "[Unnamed device]", + device_link="{}settings/sdr/{}".format(self.get_document_root(), quote(device_id)), + state=state_info, + additional_info=additional_info, + ) + + return """ +
      + {devices} +
    + + """.format( + devices="".join(render_device(key, value) for key, value in Config.get()["sdrs"].items()) + ) + + def indexAction(self): + self.serve_template("settings/general.html", **self.template_variables()) + + +class SdrFormController(SettingsFormController, metaclass=ABCMeta): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.device_id, self.device = self._get_device() + + def getTitle(self): + return self.device["name"] + + def render_sections(self): + return """ + {tabs} +
    + {sections} +
    + """.format( + tabs=self.render_tabs(), + sections=super().render_sections(), + ) + + def render_tabs(self): + return """ + + """.format( + device_link="{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id)), + device_name=self.device["name"] if self.device["name"] else "[Unnamed device]", + device_active="active" if self.isDeviceActive() else "", + new_profile_active="active" if self.isNewProfileActive() else "", + new_profile_link="{}settings/sdr/{}/newprofile".format(self.get_document_root(), quote(self.device_id)), + profile_tabs="".join( + """ + + """.format( + profile_link="{}settings/sdr/{}/profile/{}".format( + self.get_document_root(), quote(self.device_id), quote(profile_id) + ), + profile_name=profile["name"] if profile["name"] else "[Unnamed profile]", + profile_active="active" if self.isProfileActive(profile_id) else "", + ) + for profile_id, profile in self.device["profiles"].items() + ), + ) + + def isDeviceActive(self) -> bool: + return False + + def isProfileActive(self, profile_id) -> bool: + return False + + def isNewProfileActive(self) -> bool: + return False + + def store(self): + # need to overwrite the existing key in the config since the layering won't capture the changes otherwise + config = Config.get() + sdrs = config["sdrs"] + sdrs[self.device_id] = self.device + config["sdrs"] = sdrs + super().store() + + def _get_device(self): + config = Config.get() + device_id = unquote(self.request.matches.group(1)) + if device_id not in config["sdrs"]: + return None, None + return device_id, config["sdrs"][device_id] + + +class SdrFormControllerWithModal(SdrFormController, metaclass=ABCMeta): + def render_remove_button(self): + return "" + + def render_buttons(self): + return self.render_remove_button() + super().render_buttons() + + def buildModal(self): + return """ + + """.format( + object_type=self.getModalObjectType(), + confirm_url=self.getModalConfirmUrl(), + ) + + @abstractmethod + def getModalObjectType(self): + pass + + @abstractmethod + def getModalConfirmUrl(self): + pass + + +class SdrDeviceController(SdrFormControllerWithModal): + def get_breadcrumb(self) -> Breadcrumb: + return SdrDeviceBreadcrumb().append( + BreadcrumbItem(self.device["name"], "settings/sdr/{}".format(self.device_id)) + ) + + def getData(self): + return self.device + + def getSections(self): + try: + description = SdrDeviceDescription.getByType(self.device["type"]) + return [description.getDeviceSection()] + except SdrDeviceDescriptionMissing: + # TODO provide a generic interface that allows to switch the type + return [] + + def render_remove_button(self): + return """ + + """ + + def isDeviceActive(self) -> bool: + return True + + def indexAction(self): + if self.device is None: + self.send_response("device not found", code=404) + return + super().indexAction() + + def processFormData(self): + if self.device is None: + self.send_response("device not found", code=404) + return + return super().processFormData() + + def getModalObjectType(self): + return "SDR device" + + def getModalConfirmUrl(self): + return "{}settings/deletesdr/{}".format(self.get_document_root(), quote(self.device_id)) + + def deleteDevice(self): + if self.device_id is None: + return self.send_response("device not found", code=404) + config = Config.get() + sdrs = config["sdrs"] + del sdrs[self.device_id] + # need to overwrite the existing key in the config since the layering won't capture the changes otherwise + config["sdrs"] = sdrs + config.store() + return self.send_redirect("{}settings/sdr".format(self.get_document_root())) + + +class NewSdrDeviceController(SettingsFormController): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.data_layer = PropertyLayer(name="", type="", profiles=PropertyLayer()) + self.device_id = str(uuid4()) + + def get_breadcrumb(self) -> Breadcrumb: + return SdrDeviceBreadcrumb().append(BreadcrumbItem("New device", "settings/sdr/newsdr")) + + def getSections(self): + return [ + Section( + "New device settings", + TextInput("name", "Device name", validator=RequiredValidator()), + DropdownInput( + "type", + "Device type", + [Option(sdr_type, name) for sdr_type, name in SdrDeviceDescription.getTypes().items()], + infotext="Note: Switching the type will not be possible after creation since the set of available " + + "options is different for each type.
    Note: This dropdown only shows device types that have " + + "their requirements met. If a type is missing from the list, please check the feature report.", + ), + ) + ] + + def getTitle(self): + return "New device" + + def getData(self): + return self.data_layer + + def store(self): + # need to overwrite the existing key in the config since the layering won't capture the changes otherwise + config = Config.get() + sdrs = config["sdrs"] + # a uuid should be unique, so i'm not sure if there's a point in this check + if self.device_id in sdrs: + raise ValueError("device {} already exists!".format(self.device_id)) + sdrs[self.device_id] = self.data_layer + config["sdrs"] = sdrs + super().store() + + def getSuccessfulRedirect(self): + return "{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id)) + + +class SdrProfileController(SdrFormControllerWithModal): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.profile_id, self.profile = self._get_profile() + + def get_breadcrumb(self) -> Breadcrumb: + return ( + SdrDeviceBreadcrumb() + .append(BreadcrumbItem(self.device["name"], "settings/sdr/{}".format(self.device_id))) + .append( + BreadcrumbItem( + self.profile["name"], "settings/sdr/{}/profile/{}".format(self.device_id, self.profile_id) + ) + ) + ) + + def getData(self): + return self.profile + + def _get_profile(self): + if self.device is None: + return None + profile_id = unquote(self.request.matches.group(2)) + if profile_id not in self.device["profiles"]: + return None + return profile_id, self.device["profiles"][profile_id] + + def isProfileActive(self, profile_id) -> bool: + return profile_id == self.profile_id + + def getSections(self): + try: + description = SdrDeviceDescription.getByType(self.device["type"]) + return [description.getProfileSection()] + except SdrDeviceDescriptionMissing: + # TODO provide a generic interface that allows to switch the type + return [] + + def indexAction(self): + if self.profile is None: + self.send_response("profile not found", code=404) + return + super().indexAction() + + def processFormData(self): + if self.profile is None: + self.send_response("profile not found", code=404) + return + return super().processFormData() + + def render_remove_button(self): + return """ + + """ + + def getModalObjectType(self): + return "profile" + + def getModalConfirmUrl(self): + return "{}settings/sdr/{}/deleteprofile/{}".format( + self.get_document_root(), quote(self.device_id), quote(self.profile_id) + ) + + def deleteProfile(self): + if self.profile_id is None: + return self.send_response("profile not found", code=404) + config = Config.get() + del self.device["profiles"][self.profile_id] + config.store() + return self.send_redirect("{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id))) + + +class NewProfileController(SdrProfileController): + def __init__(self, handler, request, options): + self.data_layer = PropertyLayer(name="") + super().__init__(handler, request, options) + + def get_breadcrumb(self) -> Breadcrumb: + return ( + SdrDeviceBreadcrumb() + .append(BreadcrumbItem(self.device["name"], "settings/sdr/{}".format(self.device_id))) + .append(BreadcrumbItem("New profile", "settings/sdr/{}/newprofile".format(self.device_id))) + ) + + def _get_profile(self): + return str(uuid4()), self.data_layer + + def isNewProfileActive(self) -> bool: + return True + + def store(self): + # a uuid should be unique, so i'm not sure if there's a point in this check + if self.profile_id in self.device["profiles"]: + raise ValueError("Profile {} already exists!".format(self.profile_id)) + self.device["profiles"][self.profile_id] = self.data_layer + super().store() + + def getSuccessfulRedirect(self): + return "{}settings/sdr/{}/profile/{}".format( + self.get_document_root(), quote(self.device_id), quote(self.profile_id) + ) + + def render_remove_button(self): + # new profile doesn't have a remove button + return "" diff --git a/openwebrx/owrx/controllers/status.py b/openwebrx/owrx/controllers/status.py new file mode 100644 index 0000000..b45292a --- /dev/null +++ b/openwebrx/owrx/controllers/status.py @@ -0,0 +1,44 @@ +from .receiverid import ReceiverIdController +from owrx.version import openwebrx_version +from owrx.sdr import SdrService +from owrx.config import Config +from owrx.jsons import Encoder +import json + +import logging + +logger = logging.getLogger(__name__) + + +class StatusController(ReceiverIdController): + def getProfileStats(self, profile): + return { + "name": profile["name"], + "center_freq": profile["center_freq"], + "sample_rate": profile["samp_rate"], + } + + def getReceiverStats(self, receiver): + stats = { + "name": receiver.getName(), + # TODO would be better to have types from the config here + "type": type(receiver).__name__, + "profiles": [self.getProfileStats(p) for p in receiver.getProfiles().values()], + } + return stats + + def indexAction(self): + pm = Config.get() + status = { + "receiver": { + "name": pm["receiver_name"], + "admin": pm["receiver_admin"], + "gps": pm["receiver_gps"], + "asl": pm["receiver_asl"], + "location": pm["receiver_location"], + }, + "max_clients": pm["max_clients"], + "version": openwebrx_version, + "sdrs": [self.getReceiverStats(r) for r in SdrService.getActiveSources().values()], + } + self.send_response(json.dumps(status, cls=Encoder), content_type="application/json") diff --git a/openwebrx/owrx/controllers/template.py b/openwebrx/owrx/controllers/template.py new file mode 100644 index 0000000..f7e1a53 --- /dev/null +++ b/openwebrx/owrx/controllers/template.py @@ -0,0 +1,45 @@ +from owrx.controllers import Controller +from owrx.details import ReceiverDetails +from string import Template +import pkg_resources + + +class TemplateController(Controller): + def render_template(self, file, **vars): + file_content = pkg_resources.resource_string("htdocs", file).decode("utf-8") + template = Template(file_content) + + return template.safe_substitute(**vars) + + def serve_template(self, file, **vars): + self.send_response(self.render_template(file, **vars), content_type="text/html") + + def default_variables(self): + return {} + + +class WebpageController(TemplateController): + def get_document_root(self): + path_parts = [part for part in self.request.path[1:].split("/")] + levels = max(0, len(path_parts) - 1) + return "../" * levels + + def header_variables(self): + variables = {"document_root": self.get_document_root()} + variables.update(ReceiverDetails().__dict__()) + return variables + + def template_variables(self): + header = self.render_template("include/header.include.html", **self.header_variables()) + return {"header": header, "document_root": self.get_document_root()} + + +class IndexController(WebpageController): + def indexAction(self): + self.serve_template("index.html", **self.template_variables()) + + +class MapController(WebpageController): + def indexAction(self): + # TODO check if we have a google maps api key first? + self.serve_template("map.html", **self.template_variables()) diff --git a/openwebrx/owrx/controllers/websocket.py b/openwebrx/owrx/controllers/websocket.py new file mode 100644 index 0000000..3363abf --- /dev/null +++ b/openwebrx/owrx/controllers/websocket.py @@ -0,0 +1,10 @@ +from . import Controller +from owrx.websocket import WebSocketConnection +from owrx.connection import HandshakeMessageHandler + + +class WebSocketController(Controller): + def indexAction(self): + conn = WebSocketConnection(self.handler, HandshakeMessageHandler()) + # enter read loop + conn.handle() diff --git a/openwebrx/owrx/cpu.py b/openwebrx/owrx/cpu.py new file mode 100644 index 0000000..cad912f --- /dev/null +++ b/openwebrx/owrx/cpu.py @@ -0,0 +1,77 @@ +import threading + +import logging + +logger = logging.getLogger(__name__) + + +class CpuUsageThread(threading.Thread): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with CpuUsageThread.creationLock: + if CpuUsageThread.sharedInstance is None: + CpuUsageThread.sharedInstance = CpuUsageThread() + return CpuUsageThread.sharedInstance + + def __init__(self): + self.clients = [] + self.doRun = True + self.last_worktime = 0 + self.last_idletime = 0 + self.endEvent = threading.Event() + super().__init__() + + def run(self): + logger.debug("cpu usage thread starting up") + while self.doRun: + try: + cpu_usage = self.get_cpu_usage() + except: + cpu_usage = 0 + for c in self.clients: + c.write_cpu_usage(cpu_usage) + self.endEvent.wait(timeout=3) + logger.debug("cpu usage thread shut down") + + def get_cpu_usage(self): + try: + f = open("/proc/stat", "r") + except: + return 0 # Workaround, possibly we're on a Mac + line = "" + while not "cpu " in line: + line = f.readline() + f.close() + spl = line.split(" ") + worktime = int(spl[2]) + int(spl[3]) + int(spl[4]) + idletime = int(spl[5]) + dworktime = worktime - self.last_worktime + didletime = idletime - self.last_idletime + rate = float(dworktime) / (didletime + dworktime) + self.last_worktime = worktime + self.last_idletime = idletime + if self.last_worktime == 0: + return 0 + return rate + + def add_client(self, c): + self.clients.append(c) + if not self.is_alive(): + self.start() + + def remove_client(self, c): + try: + self.clients.remove(c) + except ValueError: + pass + if not self.clients: + self.shutdown() + + def shutdown(self): + with CpuUsageThread.creationLock: + CpuUsageThread.sharedInstance = None + self.doRun = False + self.endEvent.set() diff --git a/openwebrx/owrx/details.py b/openwebrx/owrx/details.py new file mode 100644 index 0000000..8bdfd3e --- /dev/null +++ b/openwebrx/owrx/details.py @@ -0,0 +1,24 @@ +from owrx.config import Config +from owrx.locator import Locator +from owrx.property import PropertyFilter +from owrx.property.filter import ByPropertyName + + +class ReceiverDetails(PropertyFilter): + def __init__(self): + super().__init__( + Config.get(), + ByPropertyName( + "receiver_name", + "receiver_location", + "receiver_asl", + "receiver_gps", + "photo_title", + "photo_desc", + ) + ) + + def __dict__(self): + receiver_info = super().__dict__() + receiver_info["locator"] = Locator.fromCoordinates(receiver_info["receiver_gps"]) + return receiver_info diff --git a/openwebrx/owrx/dsp.py b/openwebrx/owrx/dsp.py new file mode 100644 index 0000000..b6a569f --- /dev/null +++ b/openwebrx/owrx/dsp.py @@ -0,0 +1,221 @@ +from owrx.meta import MetaParser +from owrx.wsjt import WsjtParser +from owrx.js8 import Js8Parser +from owrx.aprs import AprsParser +from owrx.pocsag import PocsagParser +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass +from owrx.property import PropertyStack, PropertyLayer, PropertyValidator +from owrx.property.validators import OrValidator, RegexValidator, BoolValidator +from owrx.modes import Modes +from owrx.config.core import CoreConfig +from csdr.output import Output +from csdr import Dsp +import threading +import re + +import logging + +logger = logging.getLogger(__name__) + + +class ModulationValidator(OrValidator): + """ + This validator only allows alphanumeric characters and numbers, but no spaces or special characters + """ + + def __init__(self): + super().__init__(BoolValidator(), RegexValidator(re.compile("^[a-z0-9]+$"))) + + +class DspManager(Output, SdrSourceEventClient): + def __init__(self, handler, sdrSource): + self.handler = handler + self.sdrSource = sdrSource + self.parsers = { + "meta": MetaParser(self.handler), + "wsjt_demod": WsjtParser(self.handler), + "packet_demod": AprsParser(self.handler), + "pocsag_demod": PocsagParser(self.handler), + "js8_demod": Js8Parser(self.handler), + } + + self.props = PropertyStack() + + # local demodulator properties not forwarded to the sdr + # ensure strict validation since these can be set from the client + # and are used to build executable commands + validators = { + "output_rate": "int", + "hd_output_rate": "int", + "squelch_level": "num", + "secondary_mod": ModulationValidator(), + "low_cut": "num", + "high_cut": "num", + "offset_freq": "int", + "mod": ModulationValidator(), + "secondary_offset_freq": "int", + "dmr_filter": "int", + } + self.localProps = PropertyValidator(PropertyLayer().filter(*validators.keys()), validators) + + self.props.addLayer(0, self.localProps) + # properties that we inherit from the sdr + self.props.addLayer( + 1, + self.sdrSource.getProps().filter( + "audio_compression", + "fft_compression", + "digimodes_fft_size", + "samp_rate", + "center_freq", + "start_mod", + "start_freq", + "wfm_deemphasis_tau", + "digital_voice_codecserver", + ), + ) + + self.dsp = Dsp(self) + self.dsp.nc_port = self.sdrSource.getPort() + + def set_low_cut(cut): + bpf = self.dsp.get_bpf() + bpf[0] = cut + self.dsp.set_bpf(*bpf) + + def set_high_cut(cut): + bpf = self.dsp.get_bpf() + bpf[1] = cut + self.dsp.set_bpf(*bpf) + + def set_dial_freq(changes): + if ( + "center_freq" not in self.props + or self.props["center_freq"] is None + or "offset_freq" not in self.props + or self.props["offset_freq"] is None + ): + return + freq = self.props["center_freq"] + self.props["offset_freq"] + for parser in self.parsers.values(): + parser.setDialFrequency(freq) + + if "start_mod" in self.props: + self.dsp.set_demodulator(self.props["start_mod"]) + mode = Modes.findByModulation(self.props["start_mod"]) + + if mode and mode.bandpass: + self.dsp.set_bpf(mode.bandpass.low_cut, mode.bandpass.high_cut) + else: + self.dsp.set_bpf(-4000, 4000) + + if "start_freq" in self.props and "center_freq" in self.props: + self.dsp.set_offset_freq(self.props["start_freq"] - self.props["center_freq"]) + else: + self.dsp.set_offset_freq(0) + + self.subscriptions = [ + self.props.wireProperty("audio_compression", self.dsp.set_audio_compression), + self.props.wireProperty("fft_compression", self.dsp.set_fft_compression), + self.props.wireProperty("digimodes_fft_size", self.dsp.set_secondary_fft_size), + self.props.wireProperty("samp_rate", self.dsp.set_samp_rate), + self.props.wireProperty("output_rate", self.dsp.set_output_rate), + self.props.wireProperty("hd_output_rate", self.dsp.set_hd_output_rate), + self.props.wireProperty("offset_freq", self.dsp.set_offset_freq), + self.props.wireProperty("center_freq", self.dsp.set_center_freq), + self.props.wireProperty("squelch_level", self.dsp.set_squelch_level), + self.props.wireProperty("low_cut", set_low_cut), + self.props.wireProperty("high_cut", set_high_cut), + self.props.wireProperty("mod", self.dsp.set_demodulator), + self.props.wireProperty("dmr_filter", self.dsp.set_dmr_filter), + self.props.wireProperty("wfm_deemphasis_tau", self.dsp.set_wfm_deemphasis_tau), + self.props.wireProperty("digital_voice_codecserver", self.dsp.set_codecserver), + self.props.filter("center_freq", "offset_freq").wire(set_dial_freq), + ] + + self.dsp.set_temporary_directory(CoreConfig().get_temporary_directory()) + + def send_secondary_config(*args): + self.handler.write_secondary_dsp_config( + { + "secondary_fft_size": self.props["digimodes_fft_size"], + "if_samp_rate": self.dsp.if_samp_rate(), + "secondary_bw": self.dsp.secondary_bw(), + } + ) + + def set_secondary_mod(mod): + if mod == False: + mod = None + self.dsp.set_secondary_demodulator(mod) + if mod is not None: + send_secondary_config() + + self.subscriptions += [ + self.props.wireProperty("secondary_mod", set_secondary_mod), + self.props.wireProperty("digimodes_fft_size", send_secondary_config), + self.props.wireProperty("secondary_offset_freq", self.dsp.set_secondary_offset_freq), + ] + + self.startOnAvailable = False + + self.sdrSource.addClient(self) + + super().__init__() + + def start(self): + if self.sdrSource.isAvailable(): + self.dsp.start() + else: + self.startOnAvailable = True + + def receive_output(self, t, read_fn): + logger.debug("adding new output of type %s", t) + writers = { + "audio": self.handler.write_dsp_data, + "hd_audio": self.handler.write_hd_audio, + "smeter": self.handler.write_s_meter_level, + "secondary_fft": self.handler.write_secondary_fft, + "secondary_demod": self.handler.write_secondary_demod, + } + for demod, parser in self.parsers.items(): + writers[demod] = parser.parse + + write = writers[t] + + threading.Thread(target=self.pump(read_fn, write), name="dsp_pump_{}".format(t)).start() + + def stop(self): + self.dsp.stop() + self.startOnAvailable = False + self.sdrSource.removeClient(self) + for sub in self.subscriptions: + sub.cancel() + self.subscriptions = [] + + def setProperties(self, props): + for k, v in props.items(): + self.setProperty(k, v) + + def setProperty(self, prop, value): + self.localProps[prop] = value + + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.USER + + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.RUNNING: + logger.debug("received STATE_RUNNING, attempting DspSource restart") + if self.startOnAvailable: + self.dsp.start() + self.startOnAvailable = False + elif state is SdrSourceState.STOPPING: + logger.debug("received STATE_STOPPING, shutting down DspSource") + self.dsp.stop() + + def onFail(self): + logger.debug("received onFail(), shutting down DspSource") + self.dsp.stop() + + def onShutdown(self): + self.dsp.stop() diff --git a/openwebrx/owrx/feature.py b/openwebrx/owrx/feature.py new file mode 100644 index 0000000..a1fff41 --- /dev/null +++ b/openwebrx/owrx/feature.py @@ -0,0 +1,568 @@ +import subprocess +from functools import reduce +from operator import and_ +import re +from distutils.version import LooseVersion +import inspect +from owrx.config.core import CoreConfig +from owrx.config import Config +import shlex +import os +from datetime import datetime, timedelta + +import logging + +logger = logging.getLogger(__name__) + + +class UnknownFeatureException(Exception): + pass + + +class FeatureCache(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if FeatureCache.sharedInstance is None: + FeatureCache.sharedInstance = FeatureCache() + return FeatureCache.sharedInstance + + def __init__(self): + self.cache = {} + self.cachetime = timedelta(hours=2) + + def has(self, feature): + if feature not in self.cache: + return False + now = datetime.now() + if self.cache[feature]["valid_to"] < now: + return False + return True + + def get(self, feature): + return self.cache[feature]["value"] + + def set(self, feature, value): + valid_to = datetime.now() + self.cachetime + self.cache[feature] = {"value": value, "valid_to": valid_to} + + +class FeatureDetector(object): + features = { + # core features; we won't start without these + "core": ["csdr", "nmux", "nc"], + # different types of sdrs and their requirements + "rtl_sdr": ["rtl_connector"], + "rtl_sdr_soapy": ["soapy_connector", "soapy_rtl_sdr"], + "rtl_tcp": ["rtl_tcp_connector"], + "sdrplay": ["soapy_connector", "soapy_sdrplay"], + "hackrf": ["soapy_connector", "soapy_hackrf"], + "perseussdr": ["perseustest"], + "airspy": ["soapy_connector", "soapy_airspy"], + "airspyhf": ["soapy_connector", "soapy_airspyhf"], + "lime_sdr": ["soapy_connector", "soapy_lime_sdr"], + "fifi_sdr": ["alsa", "rockprog"], + "pluto_sdr": ["soapy_connector", "soapy_pluto_sdr"], + "soapy_remote": ["soapy_connector", "soapy_remote"], + "uhd": ["soapy_connector", "soapy_uhd"], + "radioberry": ["soapy_connector", "soapy_radioberry"], + "fcdpp": ["soapy_connector", "soapy_fcdpp"], + "sddc": ["sddc_connector"], + "hpsdr": ["hpsdr_connector"], + "runds": ["runds_connector"], + # optional features and their requirements + "digital_voice_digiham": ["digiham", "sox", "codecserver_ambe"], + "digital_voice_freedv": ["freedv_rx", "sox"], + "digital_voice_m17": ["m17_demod", "sox", "digiham"], + "wsjt-x": ["wsjtx", "sox"], + "wsjt-x-2-3": ["wsjtx_2_3", "sox"], + "wsjt-x-2-4": ["wsjtx_2_4", "sox"], + "packet": ["direwolf", "sox"], + "pocsag": ["digiham", "sox"], + "js8call": ["js8", "sox"], + "drm": ["dream", "sox"], + } + + def feature_availability(self): + return {name: self.is_available(name) for name in FeatureDetector.features} + + def feature_report(self): + def requirement_details(name): + available = self.has_requirement(name) + return { + "available": available, + # as of now, features are always enabled as soon as they are available. this may change in the future. + "enabled": available, + "description": self.get_requirement_description(name), + } + + def feature_details(name): + return { + "available": self.is_available(name), + "requirements": {name: requirement_details(name) for name in self.get_requirements(name)}, + } + + return {name: feature_details(name) for name in FeatureDetector.features} + + def is_available(self, feature): + return self.has_requirements(self.get_requirements(feature)) + + def get_requirements(self, feature): + try: + return FeatureDetector.features[feature] + except KeyError: + raise UnknownFeatureException('Feature "{0}" is not known.'.format(feature)) + + def has_requirements(self, requirements): + passed = True + for requirement in requirements: + passed = passed and self.has_requirement(requirement) + return passed + + def _get_requirement_method(self, requirement): + methodname = "has_" + requirement + if hasattr(self, methodname) and callable(getattr(self, methodname)): + return getattr(self, methodname) + return None + + def has_requirement(self, requirement): + cache = FeatureCache.getSharedInstance() + if cache.has(requirement): + return cache.get(requirement) + + method = self._get_requirement_method(requirement) + result = False + if method is not None: + result = method() + else: + logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement)) + + cache.set(requirement, result) + return result + + def get_requirement_description(self, requirement): + return inspect.getdoc(self._get_requirement_method(requirement)) + + def command_is_runnable(self, command, expected_result=None): + tmp_dir = CoreConfig().get_temporary_directory() + cmd = shlex.split(command) + env = os.environ.copy() + # prevent X11 programs from opening windows if called from a GUI shell + env.pop("DISPLAY", None) + try: + process = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=tmp_dir, + env=env, + ) + rc = process.wait() + if expected_result is None: + return rc != 32512 + else: + return rc == expected_result + except FileNotFoundError: + return False + + def has_csdr(self): + """ + OpenWebRX uses the demodulator and pipeline tools provided by the csdr project. Please check out [the project + page on github](https://github.com/jketterl/csdr) for further details and installation instructions. + """ + required_version = LooseVersion("0.17.0") + + csdr_version_regex = re.compile("^csdr version (.*)$") + + try: + process = subprocess.Popen(["csdr", "version"], stderr=subprocess.PIPE) + matches = csdr_version_regex.match(process.stderr.readline().decode()) + if matches is None: + return False + version = LooseVersion(matches.group(1)) + process.wait(1) + return version >= required_version + except FileNotFoundError: + return False + + def has_nmux(self): + """ + Nmux is another tool provided by the csdr project. It is used for internal multiplexing of the IQ data streams. + If you're missing nmux even though you have csdr installed, please update your csdr version. + """ + return self.command_is_runnable("nmux --help") + + def has_nc(self): + """ + Nc is the client used to connect to the nmux multiplexer. It is provided by either the BSD netcat (recommended + for better performance) or GNU netcat packages. Please check your distribution package manager for options. + """ + return self.command_is_runnable("nc --help") + + def has_perseustest(self): + """ + To use a Microtelecom Perseus HF receiver, compile and + install the libperseus-sdr: + ``` + sudo apt install libusb-1.0-0-dev + cd /tmp + wget https://github.com/Microtelecom/libperseus-sdr/releases/download/v0.8.2/libperseus_sdr-0.8.2.tar.gz + tar -zxvf libperseus_sdr-0.8.2.tar.gz + cd libperseus_sdr-0.8.2/ + ./configure + make + sudo make install + sudo ldconfig + perseustest + ``` + """ + return self.command_is_runnable("perseustest -h") + + def has_digiham(self): + """ + To use digital voice modes, the digiham package is required. You can find the package and installation + instructions [here](https://github.com/jketterl/digiham). + + Please note: there is close interaction between digiham and openwebrx, so older versions will probably not work. + If you have an older verison of digiham installed, please update it along with openwebrx. + As of now, we require version 0.3 of digiham. + """ + required_version = LooseVersion("0.5") + + digiham_version_regex = re.compile("^(.*) version (.*)$") + + def check_digiham_version(command): + try: + process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE) + matches = digiham_version_regex.match(process.stdout.readline().decode()) + if matches is None: + return False + version = LooseVersion(matches.group(2)) + process.wait(1) + return matches.group(1) in [command, "digiham"] and version >= required_version + except FileNotFoundError: + return False + + return reduce( + and_, + map( + check_digiham_version, + [ + "rrc_filter", + "ysf_decoder", + "dmr_decoder", + "mbe_synthesizer", + "gfsk_demodulator", + "digitalvoice_filter", + "fsk_demodulator", + "pocsag_decoder", + "dstar_decoder", + "nxdn_decoder", + "dc_block", + ], + ), + True, + ) + + def _check_connector(self, command, required_version): + owrx_connector_version_regex = re.compile("^{} version (.*)$".format(re.escape(command))) + + try: + process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE) + matches = owrx_connector_version_regex.match(process.stdout.readline().decode()) + if matches is None: + return False + version = LooseVersion(matches.group(1)) + process.wait(1) + return version >= required_version + except FileNotFoundError: + return False + + def _check_owrx_connector(self, command): + return self._check_connector(command, LooseVersion("0.5")) + + def has_rtl_connector(self): + """ + The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker + frequency switching, uses less CPU and can even provide more stability in some cases. + + You can get it [here](https://github.com/jketterl/owrx_connector). + """ + return self._check_owrx_connector("rtl_connector") + + def has_rtl_tcp_connector(self): + """ + The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker + frequency switching, uses less CPU and can even provide more stability in some cases. + + You can get it [here](https://github.com/jketterl/owrx_connector). + """ + return self._check_owrx_connector("rtl_tcp_connector") + + def has_soapy_connector(self): + """ + The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker + frequency switching, uses less CPU and can even provide more stability in some cases. + + You can get it [here](https://github.com/jketterl/owrx_connector). + """ + return self._check_owrx_connector("soapy_connector") + + def _has_soapy_driver(self, driver): + try: + process = subprocess.Popen(["SoapySDRUtil", "--info"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + factory_regex = re.compile("^Available factories\\.\\.\\. ?(.*)$") + + drivers = [] + for line in process.stdout: + matches = factory_regex.match(line.decode()) + if matches: + drivers = [s.strip() for s in matches.group(1).split(", ")] + + return driver in drivers + except FileNotFoundError: + return False + + def has_soapy_rtl_sdr(self): + """ + The SoapySDR module for rtl-sdr devices can be used as an alternative to the rtl_connector. It provides + additional support for the direct sampling mod. + + You can get it [here](https://github.com/pothosware/SoapyRTLSDR/wiki). + """ + return self._has_soapy_driver("rtlsdr") + + def has_soapy_sdrplay(self): + """ + The SoapySDR module for sdrplay devices is required for interfacing with SDRPlay devices (RSP1*, RSP2*, RSPDuo) + + You can get it [here](https://github.com/SDRplay/SoapySDRPlay). + """ + return self._has_soapy_driver("sdrplay") + + def has_soapy_airspy(self): + """ + The SoapySDR module for airspy devices is required for interfacing with Airspy devices (Airspy R2, Airspy Mini). + + You can get it [here](https://github.com/pothosware/SoapyAirspy/wiki). + """ + return self._has_soapy_driver("airspy") + + def has_soapy_airspyhf(self): + """ + The SoapySDR module for airspyhf devices is required for interfacing with Airspy HF devices (Airspy HF+, + Airspy HF discovery). + + You can get it [here](https://github.com/pothosware/SoapyAirspyHF/wiki). + """ + return self._has_soapy_driver("airspyhf") + + def has_soapy_lime_sdr(self): + """ + The Lime Suite installs - amongst others - a Soapy driver for the LimeSDR device series. + + You can get it [here](https://github.com/myriadrf/LimeSuite). + """ + return self._has_soapy_driver("lime") + + def has_soapy_pluto_sdr(self): + """ + The SoapySDR module for PlutoSDR devices is required for interfacing with PlutoSDR devices. + + You can get it [here](https://github.com/pothosware/SoapyPlutoSDR). + """ + return self._has_soapy_driver("plutosdr") + + def has_soapy_remote(self): + """ + The SoapyRemote allows the usage of remote SDR devices using the SoapySDRServer. + + You can get the code and find additional information [here](https://github.com/pothosware/SoapyRemote/wiki). + """ + return self._has_soapy_driver("remote") + + def has_soapy_uhd(self): + """ + The SoapyUHD module allows using UHD / USRP devices with SoapySDR. + + You can get it [here](https://github.com/pothosware/SoapyUHD/wiki). + """ + return self._has_soapy_driver("uhd") + + def has_soapy_radioberry(self): + """ + The Radioberry is a SDR hat for the Raspberry Pi. + + You can find more information, along with its SoapySDR module [here](https://github.com/pa3gsb/Radioberry-2.x). + """ + return self._has_soapy_driver("radioberry") + + def has_soapy_hackrf(self): + """ + The SoapyHackRF allows HackRF to be used with SoapySDR. + + You can get it [here](https://github.com/pothosware/SoapyHackRF/wiki). + """ + return self._has_soapy_driver("hackrf") + + def has_soapy_fcdpp(self): + """ + The SoapyFCDPP module allows the use of the Funcube Dongle Pro+. + + You can get it [here](https://github.com/pothosware/SoapyFCDPP). + """ + return self._has_soapy_driver("fcdpp") + + def has_m17_demod(self): + """ + The `m17-demod` tool is used to demodulate M17 digital voice signals. + + You can find more information [here](https://github.com/mobilinkd/m17-cxx-demod) + """ + return self.command_is_runnable("m17-demod") + + def has_sox(self): + """ + The sox audio library is used to convert between the typical 8 kHz audio sampling rate used by digital modes and + the audio sampling rate requested by the client. + + It is available for most distributions through the respective package manager. + """ + return self.command_is_runnable("sox") + + def has_direwolf(self): + """ + OpenWebRX uses the [direwolf](https://github.com/wb2osz/direwolf) software modem to decode Packet Radio and + report data back to APRS-IS. Direwolf is available from the package manager on many distributions, or you can + compile it from source. + """ + return self.command_is_runnable("direwolf --help") + + def has_airspy_rx(self): + """ + In order to use an Airspy Receiver, you need to install the airspy_rx receiver software. + """ + return self.command_is_runnable("airspy_rx --help") + + def has_wsjtx(self): + """ + To decode FT8 and other digimodes, you need to install the WSJT-X software suite. Please check the + [WSJT-X homepage](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) for ready-made packages or instructions + on how to build from source. + """ + return reduce(and_, map(self.command_is_runnable, ["jt9", "wsprd"]), True) + + def _has_wsjtx_version(self, required_version): + wsjt_version_regex = re.compile("^WSJT-X (.*)$") + + try: + process = subprocess.Popen(["wsjtx_app_version", "--version"], stdout=subprocess.PIPE) + matches = wsjt_version_regex.match(process.stdout.readline().decode()) + if matches is None: + return False + version = LooseVersion(matches.group(1)) + process.wait(1) + return version >= required_version + except FileNotFoundError: + return False + + def has_wsjtx_2_3(self): + """ + Newer digital modes (e.g. FST4, FST4) require WSJT-X in at least version 2.3. + """ + return self.has_wsjtx() and self._has_wsjtx_version(LooseVersion("2.3")) + + def has_wsjtx_2_4(self): + """ + WSJT-X version 2.4 introduced the Q65 mode. + """ + return self.has_wsjtx() and self._has_wsjtx_version(LooseVersion("2.4")) + + def has_js8(self): + """ + To decode JS8, you will need to install [JS8Call](http://js8call.com/) + + Please note that the `js8` command line decoder is not made available on $PATH by some JS8Call package builds. + You will need to manually make it available by either linking it to `/usr/bin` or by adding its location to + $PATH. + """ + return self.command_is_runnable("js8") + + def has_alsa(self): + """ + Some SDR receivers are identifying themselves as a soundcard. In order to read their data, OpenWebRX relies + on the Alsa library. It is available as a package for most Linux distributions. + """ + return self.command_is_runnable("arecord --help") + + def has_rockprog(self): + """ + The "rockprog" executable is required to send commands to your FiFiSDR. It needs to be installed separately. + + You can find instructions and downloads [here](https://o28.sischa.net/fifisdr/trac/wiki/De%3Arockprog). + """ + return self.command_is_runnable("rockprog") + + def has_freedv_rx(self): + """ + The "freedv\_rx" executable is required to demodulate FreeDV digital transmissions. It comes together with the + codec2 library, but it's only a supplemental part and not installed by default or contained in its packages. + To install it, you will need to compile codec2 from source and manually install freedv\_rx. + + Detailed installation instructions are available on the + [OpenWebRX wiki](https://github.com/jketterl/openwebrx/wiki/FreeDV-demodulator-notes). + """ + return self.command_is_runnable("freedv_rx") + + def has_dream(self): + """ + In order to be able to decode DRM broadcasts, OpenWebRX needs the "dream" DRM decoder. + + The version supplied by most distributions will not work properly on the command line, so compiling from source + with a custom set of commands is recommended. Detailed installation instructions are available on the + [OpenWebRX wiki](https://github.com/jketterl/openwebrx/wiki/DRM-demodulator-notes). + """ + return self.command_is_runnable("dream --help", 0) + + def has_sddc_connector(self): + """ + The sddc_connector allows connectivity with SDR devices powered by libsddc, e.g. RX666, RX888, HF103. + + You can find more information [here](https://github.com/jketterl/sddc_connector). + """ + return self._check_connector("sddc_connector", LooseVersion("0.1")) + + def has_hpsdr_connector(self): + """ + In order to use the HPSDR connector, you will need to install [hpsdrconnector] + (https://github.com/jancona/hpsdrconnector). + """ + return self.command_is_runnable("hpsdrconnector -h") + + def has_runds_connector(self): + """ + To use radios supporting R&S radios via EB200 or Ammos, you need to install the runds_connector. + + You can find more information [here](https://github.com/jketterl/runds_connector). + """ + return self._check_connector("runds_connector", LooseVersion("0.2")) + + def has_codecserver_ambe(self): + tmp_dir = CoreConfig().get_temporary_directory() + cmd = ["mbe_synthesizer", "--test"] + config = Config.get() + if "digital_voice_codecserver" in config: + cmd += ["--server", config["digital_voice_codecserver"]] + try: + process = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=tmp_dir, + ) + return process.wait() == 0 + except FileNotFoundError: + return False diff --git a/openwebrx/owrx/fft.py b/openwebrx/owrx/fft.py new file mode 100644 index 0000000..0900b17 --- /dev/null +++ b/openwebrx/owrx/fft.py @@ -0,0 +1,90 @@ +from owrx.config.core import CoreConfig +from owrx.config import Config +import csdr +from csdr.output import Output +import threading +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass +from owrx.property import PropertyStack + +import logging + +logger = logging.getLogger(__name__) + + +class SpectrumThread(Output, SdrSourceEventClient): + def __init__(self, sdrSource): + self.sdrSource = sdrSource + super().__init__() + + stack = PropertyStack() + stack.addLayer(0, self.sdrSource.props) + stack.addLayer(1, Config.get()) + self.props = props = stack.filter( + "samp_rate", + "fft_size", + "fft_fps", + "fft_voverlap_factor", + "fft_compression", + ) + + self.dsp = dsp = csdr.Dsp(self) + dsp.nc_port = self.sdrSource.getPort() + dsp.set_demodulator("fft") + + def set_fft_averages(changes=None): + samp_rate = props["samp_rate"] + fft_size = props["fft_size"] + fft_fps = props["fft_fps"] + fft_voverlap_factor = props["fft_voverlap_factor"] + + dsp.set_fft_averages( + int(round(1.0 * samp_rate / fft_size / fft_fps / (1.0 - fft_voverlap_factor))) + if fft_voverlap_factor > 0 + else 0 + ) + + self.subscriptions = [ + props.wireProperty("samp_rate", dsp.set_samp_rate), + props.wireProperty("fft_size", dsp.set_fft_size), + props.wireProperty("fft_fps", dsp.set_fft_fps), + props.wireProperty("fft_compression", dsp.set_fft_compression), + props.filter("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages), + ] + + set_fft_averages() + + dsp.set_temporary_directory(CoreConfig().get_temporary_directory()) + logger.debug("Spectrum thread initialized successfully.") + + def start(self): + self.sdrSource.addClient(self) + if self.sdrSource.isAvailable(): + self.dsp.start() + + def supports_type(self, t): + return t == "audio" + + def receive_output(self, type, read_fn): + threading.Thread(target=self.pump(read_fn, self.sdrSource.writeSpectrumData)).start() + + def stop(self): + self.dsp.stop() + self.sdrSource.removeClient(self) + for c in self.subscriptions: + c.cancel() + self.subscriptions = [] + + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.USER + + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.STOPPING: + self.dsp.stop() + elif state is SdrSourceState.RUNNING: + self.dsp.start() + + def onFail(self): + self.dsp.stop() + + def onShutdown(self): + self.dsp.stop() diff --git a/openwebrx/owrx/form/__init__.py b/openwebrx/owrx/form/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/owrx/form/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/form/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..1696765 Binary files /dev/null and b/openwebrx/owrx/form/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/__pycache__/error.cpython-37.pyc b/openwebrx/owrx/form/__pycache__/error.cpython-37.pyc new file mode 100644 index 0000000..daeeacf Binary files /dev/null and b/openwebrx/owrx/form/__pycache__/error.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/__pycache__/section.cpython-37.pyc b/openwebrx/owrx/form/__pycache__/section.cpython-37.pyc new file mode 100644 index 0000000..5379fb2 Binary files /dev/null and b/openwebrx/owrx/form/__pycache__/section.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/error.py b/openwebrx/owrx/form/error.py new file mode 100644 index 0000000..60eef10 --- /dev/null +++ b/openwebrx/owrx/form/error.py @@ -0,0 +1,15 @@ +class FormError(Exception): + def __init__(self, key, message): + super().__init__("Error processing form data for {}: {}".format(key, message)) + self.key = key + self.message = message + + def getKey(self): + return self.key + + def getMessage(self): + return self.message + + +class ValidationError(FormError): + pass diff --git a/openwebrx/owrx/form/input/__init__.py b/openwebrx/owrx/form/input/__init__.py new file mode 100644 index 0000000..f25279a --- /dev/null +++ b/openwebrx/owrx/form/input/__init__.py @@ -0,0 +1,411 @@ +from abc import ABC +from owrx.modes import Modes +from owrx.config import Config +from owrx.form.input.validator import Validator +from owrx.form.input.converter import Converter, NullConverter, IntConverter, FloatConverter, EnumConverter +from enum import Enum + + +class Input(ABC): + def __init__(self, id, label, infotext=None, converter: Converter = None, validator: Validator = None, disabled=False, removable=False): + self.id = id + self.label = label + self.infotext = infotext + self.converter = self.defaultConverter() if converter is None else converter + self.validator = validator + self.disabled = disabled + self.removable = removable + + def setDisabled(self, disabled=True): + self.disabled = disabled + + def setRemovable(self, removable=True): + self.removable = removable + + def defaultConverter(self): + return NullConverter() + + def bootstrap_decorate(self, input): + return """ +
    + +
    +
    + {input} + {infotext} +
    + {removebutton} +
    +
    + """.format( + id=self.id, + label=self.label, + input=input, + infotext="{text}".format(text=self.infotext) if self.infotext else "", + removable="removable" if self.removable else "", + removebutton='' + if self.removable + else "", + ) + + def input_classes(self, errors): + classes = ["form-control", "form-control-sm"] + if errors: + classes.append("is-invalid") + return " ".join(classes) + + def input_properties(self, value, errors): + props = { + "class": self.input_classes(errors), + "id": self.id, + "name": self.id, + "placeholder": self.label, + "value": value, + } + if self.disabled: + props["disabled"] = "disabled" + return props + + def render_input_properties(self, value, error): + return " ".join('{}="{}"'.format(prop, value) for prop, value in self.input_properties(value, error).items()) + + def render_errors(self, errors): + return "".join("""
    {msg}
    """.format(msg=e) for e in errors) + + def render_input_group(self, value, errors): + return """ + {input} + {errors} + """.format( + input=self.render_input(value, errors), + errors=self.render_errors(errors) + ) + + def render_input(self, value, errors): + return "".format(properties=self.render_input_properties(value, errors)) + + def render(self, config, errors): + value = config[self.id] if self.id in config else None + error = errors[self.id] if self.id in errors else [] + return self.bootstrap_decorate(self.render_input_group(self.converter.convert_to_form(value), error)) + + def parse(self, data): + if self.id in data: + value = self.converter.convert_from_form(data[self.id][0]) + if self.validator is not None: + self.validator.validate(self.id, value) + return {self.id: value} + return {} + + def getLabel(self): + return self.label + + +class TextInput(Input): + def input_properties(self, value, errors): + props = super().input_properties(value, errors) + props["type"] = "text" + return props + + +class NumberInput(Input): + def __init__(self, id, label, infotext=None, append="", converter: Converter = None, validator: Validator = None): + super().__init__(id, label, infotext, converter=converter, validator=validator) + self.step = None + self.append = append + + def defaultConverter(self): + return IntConverter() + + def input_properties(self, value, errors): + props = super().input_properties(value, errors) + props["type"] = "number" + if self.step: + props["step"] = self.step + return props + + def render_input_group(self, value, errors): + if self.append: + append = """ +
    + {append} +
    + """.format( + append=self.append + ) + else: + append = "" + + return """ +
    + {input} + {append} + {errors} +
    + """.format( + input=self.render_input(value, errors), + append=append, + errors=self.render_errors(errors) + ) + + +class FloatInput(NumberInput): + def __init__(self, id, label, infotext=None, converter: Converter = None): + super().__init__(id, label, infotext, converter=converter) + self.step = "any" + + def defaultConverter(self): + return FloatConverter() + + +class LocationInput(Input): + def render_input_group(self, value, errors): + return """ +
    + {inputs} +
    + {errors} +
    +
    +
    + """.format( + id=self.id, + rowclass="is-invalid" if errors else "", + inputs=self.render_input(value, errors), + errors=self.render_errors(errors), + key=Config.get()["google_maps_api_key"], + ) + + def render_input(self, value, errors): + return "".join(self.render_sub_input(value, id, errors) for id in ["lat", "lon"]) + + def render_sub_input(self, value, id, errors): + return """ +
    + +
    + """.format( + id="{0}-{1}".format(self.id, id), + label=self.label, + classes=self.input_classes(errors), + value=value[id], + disabled="disabled" if self.disabled else "", + ) + + def parse(self, data): + return {self.id: {k: float(data["{0}-{1}".format(self.id, k)][0]) for k in ["lat", "lon"]}} + + +class TextAreaInput(Input): + def render_input(self, value, errors): + return """ + + """.format( + id=self.id, + classes=self.input_classes(errors), + value=value, + disabled="disabled" if self.disabled else "", + ) + + +class CheckboxInput(Input): + def __init__(self, id, checkboxText, infotext=None, converter: Converter = None): + super().__init__(id, "", infotext=infotext, converter=converter) + self.checkboxText = checkboxText + + def render_input(self, value, errors): + return """ +
    + + + +
    + """.format( + id=self.id, + classes=self.input_classes(errors), + checked="checked" if value else "", + disabled="disabled" if self.disabled else "", + checkboxText=self.checkboxText, + ) + + def input_classes(self, error): + classes = ["form-check", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) + + def parse(self, data): + if self.id in data: + return {self.id: self.converter.convert_from_form("1" in data[self.id])} + return {} + + def getLabel(self): + return self.checkboxText + + +class Option(object): + # used for both MultiCheckboxInput and DropdownInput + def __init__(self, value, text): + self.value = value + self.text = text + + +class MultiCheckboxInput(Input): + def __init__(self, id, label, options, infotext=None): + super().__init__(id, label, infotext=infotext) + self.options = options + + def render_input(self, value, errors): + return "".join(self.render_checkbox(o, value, errors) for o in self.options) + + def checkbox_id(self, option): + return "{0}-{1}".format(self.id, option.value) + + def render_checkbox(self, option, value, errors): + return """ +
    + + +
    + """.format( + id=self.checkbox_id(option), + classes=self.input_classes(errors), + checked="checked" if option.value in value else "", + checkboxText=option.text, + disabled="disabled" if self.disabled else "", + ) + + def parse(self, data): + def in_response(option): + boxid = self.checkbox_id(option) + return boxid in data and data[boxid][0] == "on" + + return {self.id: [o.value for o in self.options if in_response(o)]} + + def input_classes(self, error): + classes = ["form-check", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) + + +class ServicesCheckboxInput(MultiCheckboxInput): + def __init__(self, id, label, infotext=None): + services = [Option(s.modulation, s.name) for s in Modes.getAvailableServices()] + super().__init__(id, label, services, infotext) + + +class Js8ProfileCheckboxInput(MultiCheckboxInput): + def __init__(self, id, label, infotext=None): + profiles = [ + Option("normal", "Normal (15s, 50Hz, ~16WPM)"), + Option("slow", "Slow (30s, 25Hz, ~8WPM"), + Option("fast", "Fast (10s, 80Hz, ~24WPM"), + Option("turbo", "Turbo (6s, 160Hz, ~40WPM"), + ] + super().__init__(id, label, profiles, infotext) + + +class DropdownInput(Input): + def __init__(self, id, label, options, infotext=None, converter: Converter = None): + try: + isEnum = issubclass(options, DropdownEnum) + except TypeError: + isEnum = False + if isEnum: + self.options = [o.toOption() for o in options] + if converter is None: + converter = EnumConverter(options) + else: + self.options = options + super().__init__(id, label, infotext=infotext, converter=converter) + + def render_input(self, value, errors): + return """ + + """.format( + classes=self.input_classes(errors), + id=self.id, + options=self.render_options(value), + disabled="disabled" if self.disabled else "", + ) + + def render_options(self, value): + options = [ + """ + + """.format( + text=o.text, + value=o.value, + selected="selected" if o.value == value else "", + ) + for o in self.options + ] + return "".join(options) + + +class DropdownEnum(Enum): + def toOption(self): + return Option(self.name, str(self)) + + +class ModesInput(DropdownInput): + def __init__(self, id, label): + options = [Option(m.modulation, m.name) for m in Modes.getAvailableModes()] + super().__init__(id, label, options) + + +class ExponentialInput(Input): + def __init__(self, id, label, unit, infotext=None): + super().__init__(id, label, infotext=infotext) + self.unit = unit + + def defaultConverter(self): + return IntConverter() + + def input_properties(self, value, errors): + props = super().input_properties(value, errors) + props["type"] = "number" + props["step"] = "any" + return props + + def render_input_group(self, value, errors): + append = """ +
    + +
    + """.format( + id=self.id, + disabled="disabled" if self.disabled else "", + unit=self.unit, + ) + + return """ +
    + {input} + {append} + {errors} +
    + """.format( + input=self.render_input(value, errors), + append=append, + errors=self.render_errors(errors) + ) + + def parse(self, data): + exponent_id = "{}-exponent".format(self.id) + if self.id in data and exponent_id in data: + value = int(float(data[self.id][0]) * 10 ** int(data[exponent_id][0])) + return {self.id: value} + return {} diff --git a/openwebrx/owrx/form/input/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..89af80e Binary files /dev/null and b/openwebrx/owrx/form/input/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/input/__pycache__/aprs.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/aprs.cpython-37.pyc new file mode 100644 index 0000000..b8b3cca Binary files /dev/null and b/openwebrx/owrx/form/input/__pycache__/aprs.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/input/__pycache__/converter.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/converter.cpython-37.pyc new file mode 100644 index 0000000..2974f18 Binary files /dev/null and b/openwebrx/owrx/form/input/__pycache__/converter.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/input/__pycache__/device.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/device.cpython-37.pyc new file mode 100644 index 0000000..a3b6775 Binary files /dev/null and b/openwebrx/owrx/form/input/__pycache__/device.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/input/__pycache__/gfx.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/gfx.cpython-37.pyc new file mode 100644 index 0000000..aff09b4 Binary files /dev/null and b/openwebrx/owrx/form/input/__pycache__/gfx.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/input/__pycache__/receiverid.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/receiverid.cpython-37.pyc new file mode 100644 index 0000000..5743b95 Binary files /dev/null and b/openwebrx/owrx/form/input/__pycache__/receiverid.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/input/__pycache__/validator.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/validator.cpython-37.pyc new file mode 100644 index 0000000..027c6fc Binary files /dev/null and b/openwebrx/owrx/form/input/__pycache__/validator.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/input/__pycache__/wfm.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/wfm.cpython-37.pyc new file mode 100644 index 0000000..2731393 Binary files /dev/null and b/openwebrx/owrx/form/input/__pycache__/wfm.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/input/__pycache__/wsjt.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/wsjt.cpython-37.pyc new file mode 100644 index 0000000..0ae1c29 Binary files /dev/null and b/openwebrx/owrx/form/input/__pycache__/wsjt.cpython-37.pyc differ diff --git a/openwebrx/owrx/form/input/aprs.py b/openwebrx/owrx/form/input/aprs.py new file mode 100644 index 0000000..a81eab7 --- /dev/null +++ b/openwebrx/owrx/form/input/aprs.py @@ -0,0 +1,36 @@ +from owrx.form.input import DropdownEnum + + +class AprsBeaconSymbols(DropdownEnum): + BEACON_RECEIVE_ONLY = ("R&", "Receive only IGate") + BEACON_HF_GATEWAY = ("/&", "HF Gateway") + BEACON_IGATE_GENERIC = ("I&", "Igate Generic (please use more specific overlay)") + BEACON_PSKMAIL = ("P&", "PSKmail node") + BEACON_TX_1 = ("T&", "TX IGate with path set to 1 hop") + BEACON_WIRES_X = ("W&", "Wires-X") + BEACON_TX_2 = ("2&", "TX IGate with path set to 2 hops") + + def __new__(cls, *args, **kwargs): + value, description = args + obj = object.__new__(cls) + obj._value_ = value + obj.description = description + return obj + + def __str__(self): + return "{description} ({symbol})".format(description=self.description, symbol=self.value) + + +class AprsAntennaDirections(DropdownEnum): + DIRECTION_OMNI = None + DIRECTION_N = "N" + DIRECTION_NE = "NE" + DIRECTION_E = "E" + DIRECTION_SE = "SE" + DIRECTION_S = "S" + DIRECTION_SW = "SW" + DIRECTION_W = "W" + DIRECTION_NW = "NW" + + def __str__(self): + return "omnidirectional" if self.value is None else self.value diff --git a/openwebrx/owrx/form/input/converter.py b/openwebrx/owrx/form/input/converter.py new file mode 100644 index 0000000..5f9d15b --- /dev/null +++ b/openwebrx/owrx/form/input/converter.py @@ -0,0 +1,96 @@ +from abc import ABC, abstractmethod +from owrx.jsons import Encoder +import json + + +class Converter(ABC): + @abstractmethod + def convert_to_form(self, value): + pass + + @abstractmethod + def convert_from_form(self, value): + pass + + +class NullConverter(Converter): + def convert_to_form(self, value): + return value + + def convert_from_form(self, value): + return value + + +class OptionalConverter(Converter): + """ + Transforms a special form value to None + The default is look for an empty string, but this can be used to adopt to other types. + If the default is not found, the actual value is passed to the sub_converter for further transformation. + useful for optional fields since None is not stored in the configuration + """ + + def __init__(self, sub_converter: Converter = None, defaultFormValue=""): + self.sub_converter = NullConverter() if sub_converter is None else sub_converter + self.defaultFormValue = defaultFormValue + + def convert_to_form(self, value): + return self.defaultFormValue if value is None else self.sub_converter.convert_to_form(value) + + def convert_from_form(self, value): + return None if value == self.defaultFormValue else self.sub_converter.convert_from_form(value) + + +class IntConverter(Converter): + def convert_to_form(self, value): + return str(value) + + def convert_from_form(self, value): + return int(value) + + +class FloatConverter(Converter): + def convert_to_form(self, value): + return str(value) + + def convert_from_form(self, value): + return float(value) + + +class EnumConverter(Converter): + def __init__(self, enumCls): + self.enumCls = enumCls + + def convert_to_form(self, value): + return None if value is None else self.enumCls(value).name + + def convert_from_form(self, value): + return self.enumCls[value].value + + +class JsonConverter(Converter): + def convert_to_form(self, value): + return json.dumps(value, cls=Encoder) + + def convert_from_form(self, value): + return json.loads(value) + + +class WaterfallColorsConverter(Converter): + def convert_to_form(self, value): + if value is None: + return "" + return "\n".join("#{:06x}".format(v) for v in value) + + def convert_from_form(self, value): + def parseString(s): + try: + if s.startswith("#"): + return int(s[1:], 16) + # int() with base 0 can accept "0x" prefixed hex strings, or int numbers + return int(s, 0) + except ValueError: + return None + + # \r\n or \n? this should work with both. + values = [parseString(v.strip("\r ")) for v in value.split("\n")] + return [v for v in values if v is not None] diff --git a/openwebrx/owrx/form/input/device.py b/openwebrx/owrx/form/input/device.py new file mode 100644 index 0000000..2217b49 --- /dev/null +++ b/openwebrx/owrx/form/input/device.py @@ -0,0 +1,434 @@ +from owrx.form.input import Input, CheckboxInput, DropdownInput, DropdownEnum, TextInput +from owrx.form.input.converter import OptionalConverter +from owrx.form.input.validator import RequiredValidator +from owrx.soapy import SoapySettings + + +class GainInput(Input): + def __init__(self, id, label, has_agc, gain_stages=None): + super().__init__(id, label) + self.has_agc = has_agc + self.gain_stages = gain_stages + + def render_input(self, value, errors): + try: + display_value = float(value) + except (ValueError, TypeError): + display_value = "0.0" + + return """ + + + {stageoption} + """.format( + id=self.id, + classes=self.input_classes(errors), + value=display_value, + label=self.label, + options=self.render_options(value), + stageoption="" if self.gain_stages is None else self.render_stage_option(value, errors), + disabled="disabled" if self.disabled else "", + ) + + def render_input_group(self, value, errors): + return """ +
    + {input} + {errors} +
    + """.format( + id=self.id, input=self.render_input(value, errors), errors=self.render_errors(errors) + ) + + def render_options(self, value): + options = [] + if self.has_agc: + options.append(("auto", "Enable hardware AGC")) + options.append(("manual", "Specify manual gain")), + if self.gain_stages: + options.append(("stages", "Specify gain stages individually")) + + mode = self.getMode(value) + + return "".join( + """ + + """.format( + value=v[0], text=v[1], selected="selected" if mode == v[0] else "" + ) + for v in options + ) + + def getMode(self, value): + if value is None: + return "auto" if self.has_agc else "manual" + + if value == "auto": + return "auto" + + try: + float(value) + return "manual" + except (ValueError, TypeError): + pass + + return "stages" + + def render_stage_option(self, value, errors): + try: + value_dict = {k: v for item in SoapySettings.parse(value) for k, v in item.items()} + except (AttributeError, ValueError): + value_dict = {} + + return """ + + """.format( + inputs="".join( + """ +
    + + +
    + """.format( + id=self.id, + stage=stage, + value=value_dict[stage] if stage in value_dict else "", + classes=self.input_classes(errors), + disabled="disabled" if self.disabled else "", + ) + for stage in self.gain_stages + ) + ) + + def parse(self, data): + def getStageValue(stage): + input_id = "{id}-{stage}".format(id=self.id, stage=stage) + if input_id in data: + return data[input_id][0] + else: + return None + + select_id = "{id}-select".format(id=self.id) + if select_id in data: + if self.has_agc and data[select_id][0] == "auto": + return {self.id: "auto"} + if data[select_id][0] == "manual": + input_id = "{id}-manual".format(id=self.id) + value = 0.0 + if input_id in data: + try: + value = float(data[input_id][0]) + except ValueError: + pass + return {self.id: value} + if self.gain_stages is not None and data[select_id][0] == "stages": + settings_dict = [{s: getStageValue(s)} for s in self.gain_stages] + # filter out empty ones + settings_dict = [s for s in settings_dict if next(iter(s.values()))] + return {self.id: SoapySettings.encode(settings_dict)} + + return {} + + +class BiasTeeInput(CheckboxInput): + def __init__(self): + super().__init__("bias_tee", "Enable Bias-Tee power supply") + + +class DirectSamplingOptions(DropdownEnum): + DIRECT_SAMPLING_OFF = (0, "Off") + DIRECT_SAMPLING_I = (1, "Direct Sampling (I branch)") + DIRECT_SAMPLING_Q = (2, "Direct Sampling (Q branch)") + + def __new__(cls, *args, **kwargs): + value, description = args + obj = object.__new__(cls) + obj._value_ = value + obj.description = description + return obj + + def __str__(self): + return self.description + + +class DirectSamplingInput(DropdownInput): + def __init__(self): + super().__init__( + "direct_sampling", + "Direct Sampling", + DirectSamplingOptions, + ) + + +class RemoteInput(TextInput): + def __init__(self): + super().__init__( + "remote", + "Remote IP and Port", + infotext="Remote hostname or IP and port to connect to. Format = IP:Port", + converter=OptionalConverter(), + validator=RequiredValidator(), + ) + + +class SchedulerInput(Input): + def __init__(self, id, label): + super().__init__(id, label) + self.profiles = {} + + def render(self, config, errors): + if "profiles" in config: + self.profiles = config["profiles"] + return super().render(config, errors) + + def render_profiles_select(self, value, errors, config_key, stage, extra_classes="", allow_empty=False): + stage_value = "" + if value and "schedule" in value and config_key in value["schedule"]: + stage_value = value["schedule"][config_key] + + options = "".join( + """ + + """.format( + id=p_id, + name=p["name"], + selected="selected" if stage_value == p_id else "", + ) + for p_id, p in self.profiles.items() + ) + + if allow_empty: + # prepend a special "off" option to allow a schedule slot to go unused (daylight scheduler) + options = """""".format( + selected="selected" if value is None else "" + ) + options + + return """ + + """.format( + id="{}-{}".format(self.id, stage), + classes=self.input_classes(errors), + extra_classes=extra_classes, + disabled="disabled" if self.disabled else "", + options=options, + ) + + def render_static_entires(self, value, errors): + def render_time_inputs(v): + values = ["{}:{}".format(x[0:2], x[2:4]) for x in [v[0:4], v[5:9]]] + return '
    -
    '.join( + """ + + """.format( + id="{}-{}-{}".format(self.id, "time", "start" if i == 0 else "end"), + classes=self.input_classes(errors), + disabled="disabled" if self.disabled else "", + value=v, + ) + for i, v in enumerate(values) + ) + + schedule = {"0000-0000": ""} + if value is not None and value and "schedule" in value and "type" in value and value["type"] == "static": + schedule = value["schedule"] + + rows = "".join( + """ +
    + {time_inputs} + {select} + +
    + """.format( + time_inputs=render_time_inputs(slot), + select=self.render_profiles_select(value, errors, slot, "profile"), + ) + for slot, entry in schedule.items() + ) + + return """ + {rows} + +
    + +
    + """.format( + rows=rows, + time_inputs=render_time_inputs("0000-0000"), + select=self.render_profiles_select("", errors, "0000-0000", "profile"), + ) + + def render_daylight_entries(self, value, errors): + return "".join( + """ +
    + + {select} +
    + """.format( + name=name, + select=self.render_profiles_select( + value, errors, stage, stage, extra_classes="col-9", allow_empty=True + ), + ) + for stage, name in [("day", "Day"), ("night", "Night"), ("greyline", "Greyline")] + ) + + def render_input(self, value, errors): + return """ +
    + + + +
    + """.format( + id=self.id, + classes=self.input_classes(errors), + disabled="disabled" if self.disabled else "", + options=self.render_options(value), + entries=self.render_static_entires(value, errors), + stages=self.render_daylight_entries(value, errors), + ) + + def _get_mode(self, value): + if value is not None and "type" in value: + return value["type"] + return "" + + def render_options(self, value): + options = [ + ("static", "Static scheduler"), + ("daylight", "Daylight scheduler"), + ] + + mode = self._get_mode(value) + + return "".join( + """ + + """.format( + value=value, name=name, selected="selected" if mode == value else "" + ) + for value, name in options + ) + + def parse(self, data): + def getStageValue(stage): + input_id = "{id}-{stage}".format(id=self.id, stage=stage) + if input_id in data: + # special treatment for the "off" option + if data[input_id][0] == "None": + return None + return data[input_id][0] + else: + return None + + select_id = "{id}-select".format(id=self.id) + if select_id in data: + if data[select_id][0] == "static": + keys = ["{}-{}".format(self.id, x) for x in ["time-start", "time-end", "profile"]] + lists = [data[key] for key in keys if key in data] + settings_dict = { + "{}{}-{}{}".format(start[0:2], start[3:5], end[0:2], end[3:5]): profile + for start, end, profile in zip(*lists) + } + # only apply scheduler if any slots are available + if settings_dict: + return {self.id: {"type": "static", "schedule": settings_dict}} + elif data[select_id][0] == "daylight": + settings_dict = {s: getStageValue(s) for s in ["day", "night", "greyline"]} + # filter out empty ones + settings_dict = {s: v for s, v in settings_dict.items() if v} + # only apply scheduler if any of the slots are in use + if settings_dict: + return {self.id: {"type": "daylight", "schedule": settings_dict}} + + return {} + + +class WaterfallLevelsInput(Input): + def __init__(self, id, label, infotext=None): + super().__init__(id, label, infotext=infotext) + + def render_input_group(self, value, errors): + return """ +
    + {input} +
    + {errors} + """.format( + rowclass="is-invalid" if errors else "", + id=self.id, + input=self.render_input(value, errors), + errors=self.render_errors(errors), + ) + + def getUnit(self): + return "dBFS" + + def getFields(self): + return {"min": "Minimum", "max": "Maximum"} + + def render_input(self, value, errors): + return "".join( + """ +
    + +
    + +
    + {unit} +
    +
    +
    + """.format( + id=self.id, + name=name, + label=label, + value=value[name] if value and name in value else "0", + classes=self.input_classes(errors), + disabled="disabled" if self.disabled else "", + unit=self.getUnit(), + ) + for name, label in self.getFields().items() + ) + + def parse(self, data): + def getValue(name): + key = "{}-{}".format(self.id, name) + if key in data: + return {name: float(data[key][0])} + raise KeyError("waterfall key not found") + + try: + return {self.id: {k: v for name in ["min", "max"] for k, v in getValue(name).items()}} + except KeyError: + return {} + + +class WaterfallAutoLevelsInput(WaterfallLevelsInput): + def getUnit(self): + return "dB" + + def getFields(self): + return {"min": "Lower", "max": "Upper"} diff --git a/openwebrx/owrx/form/input/gfx.py b/openwebrx/owrx/form/input/gfx.py new file mode 100644 index 0000000..24516b4 --- /dev/null +++ b/openwebrx/owrx/form/input/gfx.py @@ -0,0 +1,67 @@ +from abc import ABCMeta, abstractmethod +from owrx.form.input import Input +from datetime import datetime + + +class ImageInput(Input, metaclass=ABCMeta): + def render_input(self, value, errors): + # TODO display errors + return """ +
    + +
    + {label} +
    + + +
    + """.format( + id=self.id, + label=self.label, + url=self.cachebuster(self.getUrl()), + classes=" ".join(self.getImgClasses()), + maxsize=self.getMaxSize(), + ) + + def cachebuster(self, url: str): + return "{url}{separator}cb={cachebuster}".format( + url=url, + cachebuster=datetime.now().timestamp(), + separator="&" if "?" in url else "?", + ) + + @abstractmethod + def getUrl(self) -> str: + pass + + @abstractmethod + def getImgClasses(self) -> list: + pass + + @abstractmethod + def getMaxSize(self) -> int: + pass + + +class AvatarInput(ImageInput): + def getUrl(self) -> str: + return "../static/gfx/openwebrx-avatar.png" + + def getImgClasses(self) -> list: + return ["webrx-rx-avatar"] + + def getMaxSize(self) -> int: + # 256 kB + return 250 * 1024 + + +class TopPhotoInput(ImageInput): + def getUrl(self) -> str: + return "../static/gfx/openwebrx-top-photo.jpg" + + def getImgClasses(self) -> list: + return ["webrx-top-photo"] + + def getMaxSize(self) -> int: + # 2 MB + return 2 * 1024 * 1024 diff --git a/openwebrx/owrx/form/input/receiverid.py b/openwebrx/owrx/form/input/receiverid.py new file mode 100644 index 0000000..0812cc6 --- /dev/null +++ b/openwebrx/owrx/form/input/receiverid.py @@ -0,0 +1,10 @@ +from owrx.form.input.converter import Converter + + +class ReceiverKeysConverter(Converter): + def convert_to_form(self, value): + return "" if value is None else "\n".join(value) + + def convert_from_form(self, value): + # \r\n or \n? this should work with both. + return [v.strip("\r ") for v in value.split("\n")] diff --git a/openwebrx/owrx/form/input/validator.py b/openwebrx/owrx/form/input/validator.py new file mode 100644 index 0000000..b9b87d1 --- /dev/null +++ b/openwebrx/owrx/form/input/validator.py @@ -0,0 +1,26 @@ +from abc import ABC, abstractmethod +from owrx.form.error import ValidationError + + +class Validator(ABC): + @abstractmethod + def validate(self, key, value): + pass + + +class RequiredValidator(Validator): + def validate(self, key, value): + if value is None or value == "": + raise ValidationError(key, "Field is required") + +class RangeValidator(Validator): + def __init__(self, minValue, maxValue): + self.minValue = minValue + self.maxValue = maxValue + + def validate(self, key, value): + if value is None or value == "": + return # Ignore empty values + n = float(value) + if n < self.minValue or n > self.maxValue: + raise ValidationError(key, 'Value must be between %s and %s'%(self.minValue, self.maxValue)) diff --git a/openwebrx/owrx/form/input/wfm.py b/openwebrx/owrx/form/input/wfm.py new file mode 100644 index 0000000..544754b --- /dev/null +++ b/openwebrx/owrx/form/input/wfm.py @@ -0,0 +1,16 @@ +from owrx.form.input import DropdownEnum + + +class WfmTauValues(DropdownEnum): + TAU_50_MICRO = (50e-6, "most regions") + TAU_75_MICRO = (75e-6, "Americas and South Korea") + + def __new__(cls, *args, **kwargs): + value, description = args + obj = object.__new__(cls) + obj._value_ = value + obj.description = description + return obj + + def __str__(self): + return "{}µs ({})".format(int(self.value * 1e6), self.description) diff --git a/openwebrx/owrx/form/input/wsjt.py b/openwebrx/owrx/form/input/wsjt.py new file mode 100644 index 0000000..1410599 --- /dev/null +++ b/openwebrx/owrx/form/input/wsjt.py @@ -0,0 +1,93 @@ +from owrx.form.input import Input +from owrx.form.input.converter import JsonConverter +from owrx.wsjt import Q65Mode, Q65Interval +from owrx.modes import Modes, WsjtMode +import html + + +class Q65ModeMatrix(Input): + def checkbox_id(self, mode, interval): + return "{0}-{1}-{2}".format(self.id, mode.value, interval.value) + + def render_checkbox(self, mode: Q65Mode, interval: Q65Interval, value, errors): + return """ +
    + + +
    + """.format( + classes=self.input_classes(errors), + id=self.checkbox_id(mode, interval), + checked="checked" if "{}{}".format(mode.name, interval.value) in value else "", + checkboxText="Mode {} interval {}s".format(mode.name, interval.value), + disabled="" if interval.is_available(mode) and not self.disabled else "disabled", + ) + + def render_input_group(self, value, errors): + return """ +
    + {checkboxes} + {errors} +
    + """.format( + checkboxes=self.render_input(value, errors), + errors=self.render_errors(errors), + ) + + def render_input(self, value, errors): + return "".join( + self.render_checkbox(mode, interval, value, errors) for interval in Q65Interval for mode in Q65Mode + ) + + def input_classes(self, error): + classes = ["form-check", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) + + def parse(self, data): + def in_response(mode, interval): + boxid = self.checkbox_id(mode, interval) + return boxid in data and data[boxid][0] == "on" + + return { + self.id: [ + "{}{}".format(mode.name, interval.value) + for interval in Q65Interval + for mode in Q65Mode + if in_response(mode, interval) + ], + } + + +class WsjtDecodingDepthsInput(Input): + def defaultConverter(self): + return JsonConverter() + + def render_input(self, value, errors): + def render_mode(m): + return """ + + """.format( + mode=m.modulation, + name=m.name, + ) + + return """ + + + """.format( + id=self.id, + classes=self.input_classes(errors), + value=html.escape(value), + options="".join(render_mode(m) for m in Modes.getAvailableModes() if isinstance(m, WsjtMode)), + disabled="disabled" if self.disabled else "" + ) + + def input_classes(self, error): + return super().input_classes(error) + " wsjt-decoding-depths" diff --git a/openwebrx/owrx/form/section.py b/openwebrx/owrx/form/section.py new file mode 100644 index 0000000..1eb9b9e --- /dev/null +++ b/openwebrx/owrx/form/section.py @@ -0,0 +1,124 @@ +from owrx.form.error import FormError +from owrx.form.input import Input +from typing import List + + +class Section(object): + def __init__(self, title, *inputs): + self.title = title + self.inputs = inputs + + def render_input(self, input, data, errors): + return input.render(data, errors) + + def render_inputs(self, data, errors): + return "".join([self.render_input(i, data, errors) for i in self.inputs]) + + def classes(self): + return ["col-12", "settings-section"] + + def render(self, data, errors): + return """ +
    +

    + {title} +

    + {inputs} +
    + """.format( + classes=" ".join(self.classes()), title=self.title, inputs=self.render_inputs(data, errors) + ) + + def parse(self, data): + parsed_data = {} + errors = [] + for i in self.inputs: + try: + parsed_data.update(i.parse(data)) + except FormError as e: + errors.append(e) + except Exception as e: + errors.append(FormError(i.id, "{}: {}".format(type(e).__name__, e))) + return parsed_data, errors + + +class OptionalSection(Section): + def __init__(self, title, inputs: List[Input], mandatory, optional): + super().__init__(title, *inputs) + self.mandatory = mandatory + self.optional = optional + self.optional_inputs = [] + + def classes(self): + classes = super().classes() + classes.append("optional-section") + return classes + + def _is_optional(self, input): + return input.id in self.optional + + def render_optional_select(self): + return """ +
    +
    + +
    +
    + +
    + +
    +
    + """.format( + options="".join( + """ + + """.format( + value=input.id, + name=input.getLabel(), + ) + for input in self.optional_inputs + ) + ) + + def render_optional_inputs(self, data, errors): + return """ + + """.format( + inputs="".join(self.render_input(input, data, errors) for input in self.optional_inputs) + ) + + def render_inputs(self, data, errors): + return ( + super().render_inputs(data, errors) + + self.render_optional_select() + + self.render_optional_inputs(data, errors) + ) + + def render(self, data, errors): + indexed_inputs = {input.id: input for input in self.inputs} + visible_keys = set(self.mandatory + [k for k in self.optional if k in data or k in errors]) + optional_keys = set(k for k in self.optional if k not in data and k not in errors) + self.inputs = [input for k, input in indexed_inputs.items() if k in visible_keys] + for input in self.inputs: + if self._is_optional(input): + input.setRemovable() + self.optional_inputs = [input for k, input in indexed_inputs.items() if k in optional_keys] + for input in self.optional_inputs: + input.setRemovable() + input.setDisabled() + return super().render(data, errors) + + def parse(self, data): + data, errors = super().parse(data) + # remove optional keys if they have been removed from the form by setting them to None + for k in self.optional: + if k not in data: + data[k] = None + return data, errors diff --git a/openwebrx/owrx/http.py b/openwebrx/owrx/http.py new file mode 100644 index 0000000..050e5ef --- /dev/null +++ b/openwebrx/owrx/http.py @@ -0,0 +1,196 @@ +from owrx.controllers.status import StatusController +from owrx.controllers.template import IndexController, MapController +from owrx.controllers.feature import FeatureController +from owrx.controllers.assets import OwrxAssetsController, AprsSymbolsController, CompiledAssetsController +from owrx.controllers.websocket import WebSocketController +from owrx.controllers.api import ApiController +from owrx.controllers.metrics import MetricsController +from owrx.controllers.settings import SettingsController +from owrx.controllers.settings.general import GeneralSettingsController +from owrx.controllers.settings.sdr import ( + SdrDeviceListController, + SdrDeviceController, + SdrProfileController, + NewSdrDeviceController, + NewProfileController, +) +from owrx.controllers.settings.reporting import ReportingController +from owrx.controllers.settings.backgrounddecoding import BackgroundDecodingController +from owrx.controllers.settings.decoding import DecodingSettingsController +from owrx.controllers.settings.bookmarks import BookmarksController +from owrx.controllers.session import SessionController +from owrx.controllers.profile import ProfileController +from owrx.controllers.imageupload import ImageUploadController +from owrx.controllers.robots import RobotsController +from http.server import BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs +import re +from abc import ABC, abstractmethod +from http.cookies import SimpleCookie + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class Request(object): + def __init__(self, url, method, headers): + parsed_url = urlparse(url) + self.path = parsed_url.path + self.query = parse_qs(parsed_url.query) + self.matches = None + self.method = method + self.headers = headers + self.cookies = SimpleCookie() + if "Cookie" in headers: + self.cookies.load(headers["Cookie"]) + + def setMatches(self, matches): + self.matches = matches + + +class Route(ABC): + def __init__(self, controller, method="GET", options=None): + self.controller = controller + self.controllerOptions = options if options is not None else {} + self.method = method + + @abstractmethod + def matches(self, request): + pass + + +class StaticRoute(Route): + def __init__(self, route, controller, method="GET", options=None): + self.route = route + super().__init__(controller, method, options) + + def matches(self, request): + return request.path == self.route and self.method == request.method + + +class RegexRoute(Route): + def __init__(self, regex, controller, method="GET", options=None): + self.regex = re.compile(regex) + super().__init__(controller, method, options) + + def matches(self, request): + matches = self.regex.match(request.path) + # this is probably not the cleanest way to do it... + request.setMatches(matches) + return matches is not None and self.method == request.method + + +class Router(object): + def __init__(self): + self.routes = [ + StaticRoute("/", IndexController), + StaticRoute("/robots.txt", RobotsController), + StaticRoute("/status.json", StatusController), + RegexRoute("^/static/(.+)$", OwrxAssetsController), + RegexRoute("^/compiled/(.+)$", CompiledAssetsController), + RegexRoute("^/aprs-symbols/(.+)$", AprsSymbolsController), + StaticRoute("/ws/", WebSocketController), + RegexRoute("^(/favicon.ico)$", OwrxAssetsController), + StaticRoute("/map", MapController), + StaticRoute("/features", FeatureController), + StaticRoute("/api/features", ApiController), + StaticRoute("/metrics", MetricsController, options={"action": "prometheusAction"}), + StaticRoute("/metrics.json", MetricsController), + StaticRoute("/settings", SettingsController), + StaticRoute("/settings/general", GeneralSettingsController), + StaticRoute( + "/settings/general", GeneralSettingsController, method="POST", options={"action": "processFormData"} + ), + StaticRoute("/settings/sdr", SdrDeviceListController), + StaticRoute("/settings/newsdr", NewSdrDeviceController), + StaticRoute( + "/settings/newsdr", NewSdrDeviceController, method="POST", options={"action": "processFormData"} + ), + RegexRoute("^/settings/sdr/([^/]+)$", SdrDeviceController), + RegexRoute( + "^/settings/sdr/([^/]+)$", SdrDeviceController, method="POST", options={"action": "processFormData"} + ), + RegexRoute("^/settings/deletesdr/([^/]+)$", SdrDeviceController, options={"action": "deleteDevice"}), + RegexRoute("^/settings/sdr/([^/]+)/newprofile$", NewProfileController), + RegexRoute( + "^/settings/sdr/([^/]+)/newprofile$", + NewProfileController, + method="POST", + options={"action": "processFormData"}, + ), + RegexRoute("^/settings/sdr/([^/]+)/profile/([^/]+)$", SdrProfileController), + RegexRoute( + "^/settings/sdr/([^/]+)/profile/([^/]+)$", + SdrProfileController, + method="POST", + options={"action": "processFormData"}, + ), + RegexRoute( + "^/settings/sdr/([^/]+)/deleteprofile/([^/]+)$", + SdrProfileController, + options={"action": "deleteProfile"}, + ), + StaticRoute("/settings/bookmarks", BookmarksController), + StaticRoute("/settings/bookmarks", BookmarksController, method="POST", options={"action": "new"}), + RegexRoute("^/settings/bookmarks/(.+)$", BookmarksController, method="POST", options={"action": "update"}), + RegexRoute( + "^/settings/bookmarks/(.+)$", BookmarksController, method="DELETE", options={"action": "delete"} + ), + StaticRoute("/settings/reporting", ReportingController), + StaticRoute( + "/settings/reporting", ReportingController, method="POST", options={"action": "processFormData"} + ), + StaticRoute("/settings/backgrounddecoding", BackgroundDecodingController), + StaticRoute( + "/settings/backgrounddecoding", + BackgroundDecodingController, + method="POST", + options={"action": "processFormData"}, + ), + StaticRoute("/settings/decoding", DecodingSettingsController), + StaticRoute( + "/settings/decoding", DecodingSettingsController, method="POST", options={"action": "processFormData"} + ), + StaticRoute("/login", SessionController, options={"action": "loginAction"}), + StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), + StaticRoute("/logout", SessionController, options={"action": "logoutAction"}), + StaticRoute("/pwchange", ProfileController), + StaticRoute("/pwchange", ProfileController, method="POST", options={"action": "processPwChange"}), + StaticRoute("/imageupload", ImageUploadController), + StaticRoute("/imageupload", ImageUploadController, method="POST", options={"action": "processImage"}), + ] + + def find_route(self, request): + for r in self.routes: + if r.matches(request): + return r + + def route(self, handler, request): + route = self.find_route(request) + if route is not None: + controller = route.controller + controller(handler, request, route.controllerOptions).handle_request() + else: + handler.send_error(404, "Not Found", "The page you requested could not be found.") + + +class RequestHandler(BaseHTTPRequestHandler): + timeout = 30 + router = Router() + + def log_message(self, format, *args): + logger.debug("%s - - [%s] %s", self.address_string(), self.log_date_time_string(), format % args) + + def do_GET(self): + self.router.route(self, self._build_request("GET")) + + def do_POST(self): + self.router.route(self, self._build_request("POST")) + + def do_DELETE(self): + self.router.route(self, self._build_request("DELETE")) + + def _build_request(self, method): + return Request(self.path, method, self.headers) diff --git a/openwebrx/owrx/js8.py b/openwebrx/owrx/js8.py new file mode 100644 index 0000000..97b1195 --- /dev/null +++ b/openwebrx/owrx/js8.py @@ -0,0 +1,135 @@ +from owrx.audio import AudioChopperProfile, ConfigWiredProfileSource +from owrx.parser import Parser +import re +from js8py import Js8 +from js8py.frames import Js8FrameHeartbeat, Js8FrameCompound +from owrx.map import Map, LocatorLocation +from owrx.metrics import Metrics, CounterMetric +from owrx.config import Config +from abc import ABCMeta, abstractmethod +from owrx.reporting import ReportingEngine +from typing import List + +import logging + +logger = logging.getLogger(__name__) + + +class Js8Profile(AudioChopperProfile, metaclass=ABCMeta): + def decoding_depth(self): + pm = Config.get() + # return global default + if "js8_decoding_depth" in pm: + return pm["js8_decoding_depth"] + # default when no setting is provided + return 3 + + def getFileTimestampFormat(self): + return "%y%m%d_%H%M%S" + + def decoder_commandline(self, file): + return ["js8", "--js8", "-b", self.get_sub_mode(), "-d", str(self.decoding_depth()), file] + + @abstractmethod + def get_sub_mode(self): + pass + + +class Js8ProfileSource(ConfigWiredProfileSource): + def getPropertiesToWire(self) -> List[str]: + return ["js8_enabled_profiles"] + + def getProfiles(self) -> List[AudioChopperProfile]: + config = Config.get() + profiles = config["js8_enabled_profiles"] if "js8_enabled_profiles" in config else [] + return [self._loadProfile(p) for p in profiles] + + def _loadProfile(self, profileName): + className = "Js8{0}Profile".format(profileName[0].upper() + profileName[1:].lower()) + return globals()[className]() + + +class Js8NormalProfile(Js8Profile): + def getInterval(self): + return 15 + + def get_sub_mode(self): + return "A" + + +class Js8SlowProfile(Js8Profile): + def getInterval(self): + return 30 + + def get_sub_mode(self): + return "E" + + +class Js8FastProfile(Js8Profile): + def getInterval(self): + return 10 + + def get_sub_mode(self): + return "B" + + +class Js8TurboProfile(Js8Profile): + def getInterval(self): + return 6 + + def get_sub_mode(self): + return "C" + + +class Js8Parser(Parser): + decoderRegex = re.compile(" ?") + + def parse(self, raw): + try: + profile, freq, raw_msg = raw + self.setDialFrequency(freq) + msg = raw_msg.decode().rstrip() + if Js8Parser.decoderRegex.match(msg): + return + if msg.startswith(" EOF on input file"): + return + + frame = Js8().parse_message(msg) + self.handler.write_js8_message(frame, self.dial_freq) + + self.pushDecode() + + if (isinstance(frame, Js8FrameHeartbeat) or isinstance(frame, Js8FrameCompound)) and frame.grid: + Map.getSharedInstance().updateLocation( + frame.callsign, LocatorLocation(frame.grid), "JS8", self.band + ) + ReportingEngine.getSharedInstance().spot( + { + "callsign": frame.callsign, + "mode": "JS8", + "locator": frame.grid, + "freq": self.dial_freq + frame.freq, + "db": frame.db, + "timestamp": frame.timestamp, + "msg": str(frame), + } + ) + + except Exception: + logger.exception("error while parsing js8 message") + + def pushDecode(self): + metrics = Metrics.getSharedInstance() + band = "unknown" + if self.band is not None: + band = self.band.getName() + if band is None: + band = "unknown" + + name = "js8call.decodes.{band}.JS8".format(band=band) + metric = metrics.getMetric(name) + if metric is None: + metric = CounterMetric() + metrics.addMetric(name, metric) + + metric.inc() diff --git a/openwebrx/owrx/jsons.py b/openwebrx/owrx/jsons.py new file mode 100644 index 0000000..4b2b977 --- /dev/null +++ b/openwebrx/owrx/jsons.py @@ -0,0 +1,9 @@ +from owrx.property import PropertyManager +import json + + +class Encoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, PropertyManager): + return o.__dict__() + return super().default(o) diff --git a/openwebrx/owrx/kiss.py b/openwebrx/owrx/kiss.py new file mode 100644 index 0000000..bd0d354 --- /dev/null +++ b/openwebrx/owrx/kiss.py @@ -0,0 +1,191 @@ +import socket +import time +import logging +import random +from owrx.config import Config +from abc import ABC, abstractmethod + +logger = logging.getLogger(__name__) + +FEND = 0xC0 +FESC = 0xDB +TFEND = 0xDC +TFESC = 0xDD + +FEET_PER_METER = 3.28084 + + +class DirewolfConfigSubscriber(ABC): + @abstractmethod + def onConfigChanged(self): + pass + + +class DirewolfConfig(object): + config_keys = [ + "aprs_callsign", + "aprs_igate_enabled", + "aprs_igate_server", + "aprs_igate_password", + "receiver_gps", + "aprs_igate_symbol", + "aprs_igate_beacon", + "aprs_igate_gain", + "aprs_igate_dir", + "aprs_igate_comment", + "aprs_igate_height", + ] + + def __init__(self): + self.subscribers = [] + self.configSub = None + self.port = None + + def wire(self, subscriber: DirewolfConfigSubscriber): + self.subscribers.append(subscriber) + if self.configSub is None: + pm = Config.get() + self.configSub = pm.filter(*DirewolfConfig.config_keys).wire(self._fireChanged) + + def unwire(self, subscriber: DirewolfConfigSubscriber): + self.subscribers.remove(subscriber) + if not self.subscribers and self.configSub is not None: + self.configSub.cancel() + + def _fireChanged(self, changes): + for sub in self.subscribers: + try: + sub.onConfigChanged() + except Exception: + logger.exception("Error while notifying Direwolf subscribers") + + def getPort(self): + # direwolf has some strange hardcoded port ranges + while self.port is None: + try: + port = random.randrange(1024, 49151) + # test if port is available for use + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("localhost", port)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.close() + self.port = port + except OSError: + pass + return self.port + + def getConfig(self, is_service): + pm = Config.get() + + config = """ +ACHANNELS 1 +ADEVICE stdin null + +CHANNEL 0 +MYCALL {callsign} +MODEM 1200 + +KISSPORT {port} +AGWPORT off + """.format( + port=self.getPort(), callsign=pm["aprs_callsign"] + ) + + if is_service and pm["aprs_igate_enabled"]: + pbeacon = "" + + if pm["aprs_igate_beacon"]: + # Format beacon lat/lon + lat = pm["receiver_gps"]["lat"] + lon = pm["receiver_gps"]["lon"] + direction_ns = "N" if lat > 0 else "S" + direction_we = "E" if lon > 0 else "W" + lat = abs(lat) + lon = abs(lon) + lat = "{0:02d}^{1:05.2f}{2}".format(int(lat), (lat - int(lat)) * 60, direction_ns) + lon = "{0:03d}^{1:05.2f}{2}".format(int(lon), (lon - int(lon)) * 60, direction_we) + + # Convert height from meters to feet if specified + height = "" + if "aprs_igate_height" in pm: + try: + height_m = float(pm["aprs_igate_height"]) + height_ft = round(height_m * FEET_PER_METER) + height = "HEIGHT=" + str(height_ft) + except: + logger.error( + "Cannot parse 'aprs_igate_height', expected float: " + str(pm["aprs_igate_height"]) + ) + + pbeacon = 'PBEACON sendto=IG delay=0:30 every=60:00 symbol={symbol} lat={lat} long={lon} {height} {gain} {adir} comment="{comment}"'.format( + symbol=pm["aprs_igate_symbol"], + lat=lat, + lon=lon, + height=height, + gain="GAIN=" + str(pm["aprs_igate_gain"]) if "aprs_igate_gain" in pm else "", + adir="DIR=" + str(pm["aprs_igate_dir"]) if "aprs_igate_dir" in pm else "", + comment=pm["aprs_igate_comment"], + ) + + logger.info("APRS PBEACON String: " + pbeacon) + + config += """ +IGSERVER {server} +IGLOGIN {callsign} {password} +{pbeacon} + """.format( + server=pm["aprs_igate_server"], + callsign=pm["aprs_callsign"], + password=pm["aprs_igate_password"], + pbeacon=pbeacon, + ) + + return config + + +class KissClient(object): + def __init__(self, port): + delay = 0.5 + retries = 0 + while True: + try: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.connect(("localhost", port)) + break + except ConnectionError: + if retries > 20: + logger.error("maximum number of connection attempts reached. did direwolf start up correctly?") + raise + retries += 1 + time.sleep(delay) + + def read(self): + return self.socket.recv(1) + + +class KissDeframer(object): + def __init__(self): + self.escaped = False + self.buf = bytearray() + + def parse(self, input): + frames = [] + for b in input: + if b == FESC: + self.escaped = True + elif self.escaped: + if b == TFEND: + self.buf.append(FEND) + elif b == TFESC: + self.buf.append(FESC) + else: + logger.warning("invalid escape char: %s", str(input[0])) + self.escaped = False + elif input[0] == FEND: + # data frames start with 0x00 + if len(self.buf) > 1 and self.buf[0] == 0x00: + frames += [self.buf[1:]] + self.buf = bytearray() + else: + self.buf.append(b) + return frames diff --git a/openwebrx/owrx/locator.py b/openwebrx/owrx/locator.py new file mode 100644 index 0000000..52c37e5 --- /dev/null +++ b/openwebrx/owrx/locator.py @@ -0,0 +1,25 @@ +class Locator(object): + @staticmethod + def fromCoordinates(coordinates, depth=3): + + lat = coordinates["lat"] + lon = coordinates["lon"] + + lon = lon + 180 + lat = lat + 90 + + res = "" + res += chr(65 + int(lon / 20)) + res += chr(65 + int(lat / 10)) + if depth >= 2: + lon = lon % 20 + lat = lat % 10 + res += str(int(lon / 2)) + res += str(int(lat)) + if depth >= 3: + lon = lon % 2 + lat = lat % 1 + res += chr(97 + int(lon * 12)) + res += chr(97 + int(lat * 24)) + + return res diff --git a/openwebrx/owrx/map.py b/openwebrx/owrx/map.py new file mode 100644 index 0000000..8c95ea5 --- /dev/null +++ b/openwebrx/owrx/map.py @@ -0,0 +1,141 @@ +from datetime import datetime, timedelta +from owrx.config import Config +from owrx.bands import Band +import threading +import time +import sys + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class Location(object): + def __dict__(self): + return {} + + +class Map(object): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with Map.creationLock: + if Map.sharedInstance is None: + Map.sharedInstance = Map() + return Map.sharedInstance + + def __init__(self): + self.clients = [] + self.positions = {} + self.positionsLock = threading.Lock() + + def removeLoop(): + loops = 0 + while True: + try: + self.removeOldPositions() + except Exception: + logger.exception("error while removing old map positions") + loops += 1 + # rebuild the positions dictionary every once in a while, it consumes lots of memory otherwise + if loops == 60: + try: + self.rebuildPositions() + except Exception: + logger.exception("error while rebuilding positions") + loops = 0 + time.sleep(60) + + threading.Thread(target=removeLoop, daemon=True, name="map_removeloop").start() + super().__init__() + + def broadcast(self, update): + for c in self.clients: + c.write_update(update) + + def addClient(self, client): + self.clients.append(client) + client.write_update( + [ + { + "callsign": callsign, + "location": record["location"].__dict__(), + "lastseen": record["updated"].timestamp() * 1000, + "mode": record["mode"], + "band": record["band"].getName() if record["band"] is not None else None, + } + for (callsign, record) in self.positions.items() + ] + ) + + def removeClient(self, client): + try: + self.clients.remove(client) + except ValueError: + pass + + def updateLocation(self, callsign, loc: Location, mode: str, band: Band = None): + ts = datetime.now() + with self.positionsLock: + self.positions[callsign] = {"location": loc, "updated": ts, "mode": mode, "band": band} + self.broadcast( + [ + { + "callsign": callsign, + "location": loc.__dict__(), + "lastseen": ts.timestamp() * 1000, + "mode": mode, + "band": band.getName() if band is not None else None, + } + ] + ) + + def touchLocation(self, callsign): + # not implemented on the client side yet, so do not use! + ts = datetime.now() + with self.positionsLock: + if callsign in self.positions: + self.positions[callsign]["updated"] = ts + self.broadcast([{"callsign": callsign, "lastseen": ts.timestamp() * 1000}]) + + def removeLocation(self, callsign): + with self.positionsLock: + del self.positions[callsign] + # TODO broadcast removal to clients + + def removeOldPositions(self): + pm = Config.get() + retention = timedelta(seconds=pm["map_position_retention_time"]) + cutoff = datetime.now() - retention + + to_be_removed = [callsign for (callsign, pos) in self.positions.items() if pos["updated"] < cutoff] + for callsign in to_be_removed: + self.removeLocation(callsign) + + def rebuildPositions(self): + logger.debug("rebuilding map storage; size before: %i", sys.getsizeof(self.positions)) + with self.positionsLock: + p = {key: value for key, value in self.positions.items()} + self.positions = p + logger.debug("rebuild complete; size after: %i", sys.getsizeof(self.positions)) + + +class LatLngLocation(Location): + def __init__(self, lat: float, lon: float): + self.lat = lat + self.lon = lon + + def __dict__(self): + res = {"type": "latlon", "lat": self.lat, "lon": self.lon} + return res + + +class LocatorLocation(Location): + def __init__(self, locator: str): + self.locator = locator + + def __dict__(self): + return {"type": "locator", "locator": self.locator} diff --git a/openwebrx/owrx/meta.py b/openwebrx/owrx/meta.py new file mode 100644 index 0000000..66994d4 --- /dev/null +++ b/openwebrx/owrx/meta.py @@ -0,0 +1,166 @@ +from owrx.config import Config +from urllib import request +import json +from datetime import datetime, timedelta +import logging +import threading +from owrx.map import Map, LatLngLocation +from owrx.parser import Parser +from owrx.aprs import AprsParser, AprsLocation +from abc import ABC, abstractmethod + +logger = logging.getLogger(__name__) + + +class Enricher(ABC): + def __init__(self, parser): + self.parser = parser + + @abstractmethod + def enrich(self, meta): + pass + + +class RadioIDCache(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if RadioIDCache.sharedInstance is None: + RadioIDCache.sharedInstance = RadioIDCache() + return RadioIDCache.sharedInstance + + def __init__(self): + self.cache = {} + self.cacheTimeout = timedelta(seconds=86400) + + def isValid(self, mode, radio_id): + key = self.__key(mode, radio_id) + if key not in self.cache: + return False + entry = self.cache[key] + return entry["timestamp"] + self.cacheTimeout > datetime.now() + + def __key(self, mode, radio_id): + return "{}-{}".format(mode, radio_id) + + def put(self, mode, radio_id, value): + self.cache[self.__key(mode, radio_id)] = {"timestamp": datetime.now(), "data": value} + + def get(self, mode, radio_id): + if not self.isValid(mode, radio_id): + return None + return self.cache[self.__key(mode, radio_id)]["data"] + + +class RadioIDEnricher(Enricher): + def __init__(self, mode, parser): + super().__init__(parser) + self.mode = mode + self.threads = {} + + def _fillCache(self, id): + RadioIDCache.getSharedInstance().put(self.mode, id, self._downloadRadioIdData(id)) + del self.threads[id] + + def _downloadRadioIdData(self, id): + try: + logger.debug("requesting radioid metadata for mode=%s and id=%s", self.mode, id) + res = request.urlopen("https://www.radioid.net/api/{0}/user/?id={1}".format(self.mode, id), timeout=30) + if res.status != 200: + logger.warning("radioid API returned error %i for mode=%s and id=%s", res.status, self.mode, id) + return None + data = json.loads(res.read().decode("utf-8")) + if "count" in data and data["count"] > 0 and "results" in data: + for item in data["results"]: + if "id" in item and item["id"] == id: + return item + except json.JSONDecodeError: + logger.warning("unable to parse radioid response JSON") + + return None + + def enrich(self, meta): + config_key = "digital_voice_{}_id_lookup".format(self.mode) + if not Config.get()[config_key]: + return meta + if "source" not in meta: + return meta + id = int(meta["source"]) + cache = RadioIDCache.getSharedInstance() + if not cache.isValid(self.mode, id): + if id not in self.threads: + self.threads[id] = threading.Thread(target=self._fillCache, args=[id], daemon=True) + self.threads[id].start() + return meta + data = cache.get(self.mode, id) + if data is not None: + meta["additional"] = data + return meta + + +class YsfMetaEnricher(Enricher): + def enrich(self, meta): + for key in ["source", "up", "down", "target"]: + if key in meta: + meta[key] = meta[key].strip() + for key in ["lat", "lon"]: + if key in meta: + meta[key] = float(meta[key]) + if "source" in meta and "lat" in meta and "lon" in meta: + loc = LatLngLocation(meta["lat"], meta["lon"]) + Map.getSharedInstance().updateLocation(meta["source"], loc, "YSF", self.parser.getBand()) + return meta + + +class DStarEnricher(Enricher): + def enrich(self, meta): + for key in ["lat", "lon"]: + if key in meta: + meta[key] = float(meta[key]) + if "ourcall" in meta and "lat" in meta and "lon" in meta: + loc = LatLngLocation(meta["lat"], meta["lon"]) + Map.getSharedInstance().updateLocation(meta["ourcall"], loc, "D-Star", self.parser.getBand()) + if "dprs" in meta: + try: + # we can send the DPRS stuff through our APRS parser to extract the information + # TODO: only third-party parsing accepts this format right now + # TODO: we also need to pass a handler, which is not needed + parser = AprsParser(None) + dprsData = parser.parseThirdpartyAprsData(meta["dprs"]) + if "data" in dprsData: + data = dprsData["data"] + if "lat" in data and "lon" in data: + # TODO: we could actually get the symbols from the parsed APRS data and show that on the meta panel + meta["lat"] = data["lat"] + meta["lon"] = data["lon"] + + if "ourcall" in meta: + # send location info to map as well (it will show up with the correct symbol there!) + loc = AprsLocation(data) + Map.getSharedInstance().updateLocation(meta["ourcall"], loc, "DPRS", self.parser.getBand()) + except Exception: + logger.exception("Error while parsing DPRS data") + + return meta + + +class MetaParser(Parser): + def __init__(self, handler): + super().__init__(handler) + self.enrichers = { + "DMR": RadioIDEnricher("dmr", self), + "YSF": YsfMetaEnricher(self), + "DSTAR": DStarEnricher(self), + "NXDN": RadioIDEnricher("nxdn", self), + } + + def parse(self, meta): + fields = meta.split(";") + meta = {v[0]: ":".join(v[1:]) for v in map(lambda x: x.split(":"), fields) if v[0] != ""} + + if "protocol" in meta: + protocol = meta["protocol"] + if protocol in self.enrichers: + meta = self.enrichers[protocol].enrich(meta) + self.handler.write_metadata(meta) diff --git a/openwebrx/owrx/metrics.py b/openwebrx/owrx/metrics.py new file mode 100644 index 0000000..6600e85 --- /dev/null +++ b/openwebrx/owrx/metrics.py @@ -0,0 +1,70 @@ +import threading +from owrx.client import ClientRegistry + + +class Metric(object): + def getValue(self): + return 0 + + +class CounterMetric(Metric): + def __init__(self): + self.counter = 0 + + def inc(self, increment=1): + self.counter += increment + + def getValue(self): + return {"count": self.counter} + + +class DirectMetric(Metric): + def __init__(self, getter): + self.getter = getter + + def getValue(self): + return self.getter() + + +class Metrics(object): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with Metrics.creationLock: + if Metrics.sharedInstance is None: + Metrics.sharedInstance = Metrics() + return Metrics.sharedInstance + + def __init__(self): + self.metrics = {} + self.addMetric("openwebrx.users", DirectMetric(ClientRegistry.getSharedInstance().clientCount)) + + def addMetric(self, name, metric): + self.metrics[name] = metric + + def hasMetric(self, name): + return name in self.metrics + + def getMetric(self, name): + if not self.hasMetric(name): + return None + return self.metrics[name] + + def getFlatMetrics(self): + return self.metrics + + def getHierarchicalMetrics(self): + result = {} + + for (key, metric) in self.metrics.items(): + partial = result + keys = key.split(".") + for keypart in keys[0:-1]: + if not keypart in partial: + partial[keypart] = {} + partial = partial[keypart] + partial[keys[-1]] = metric.getValue() + + return result diff --git a/openwebrx/owrx/modes.py b/openwebrx/owrx/modes.py new file mode 100644 index 0000000..6bb292f --- /dev/null +++ b/openwebrx/owrx/modes.py @@ -0,0 +1,156 @@ +from owrx.feature import FeatureDetector +from owrx.audio import ProfileSource +from functools import reduce +from abc import ABCMeta, abstractmethod + + +class Bandpass(object): + def __init__(self, low_cut, high_cut): + self.low_cut = low_cut + self.high_cut = high_cut + + +class Mode(object): + def __init__(self, modulation, name, bandpass: Bandpass = None, requirements=None, service=False, squelch=True): + self.modulation = modulation + self.name = name + self.requirements = requirements if requirements is not None else [] + self.service = service + self.bandpass = bandpass + self.squelch = squelch + + def is_available(self): + fd = FeatureDetector() + return reduce(lambda a, b: a and b, [fd.is_available(r) for r in self.requirements], True) + + def is_service(self): + return self.service + + def get_bandpass(self): + return self.bandpass + + def get_modulation(self): + return self.modulation + + +class AnalogMode(Mode): + pass + + +class DigitalMode(Mode): + def __init__( + self, modulation, name, underlying, bandpass: Bandpass = None, requirements=None, service=False, squelch=True + ): + super().__init__(modulation, name, bandpass, requirements, service, squelch) + self.underlying = underlying + + def get_bandpass(self): + if self.bandpass is not None: + return self.bandpass + return Modes.findByModulation(self.underlying[0]).get_bandpass() + + def get_modulation(self): + return Modes.findByModulation(self.underlying[0]).get_modulation() + + +class AudioChopperMode(DigitalMode, metaclass=ABCMeta): + def __init__(self, modulation, name, bandpass=None, requirements=None): + if bandpass is None: + bandpass = Bandpass(0, 3000) + super().__init__(modulation, name, ["usb"], bandpass=bandpass, requirements=requirements, service=True) + + @abstractmethod + def get_profile_source(self) -> ProfileSource: + pass + + +class WsjtMode(AudioChopperMode): + def __init__(self, modulation, name, bandpass=None, requirements=None): + if requirements is None: + requirements = ["wsjt-x"] + super().__init__(modulation, name, bandpass=bandpass, requirements=requirements) + + def get_profile_source(self) -> ProfileSource: + # inline import due to circular dependencies + from owrx.wsjt import WsjtProfiles + return WsjtProfiles.getSource(self.modulation) + + +class Js8Mode(AudioChopperMode): + def __init__(self, modulation, name, bandpass=None, requirements=None): + if requirements is None: + requirements = ["js8call"] + super().__init__(modulation, name, bandpass, requirements) + + def get_profile_source(self) -> ProfileSource: + # inline import due to circular dependencies + from owrx.js8 import Js8ProfileSource + return Js8ProfileSource() + + +class Modes(object): + mappings = [ + AnalogMode("nfm", "FM", bandpass=Bandpass(-4000, 4000)), + AnalogMode("wfm", "WFM", bandpass=Bandpass(-75000, 75000)), + AnalogMode("am", "AM", bandpass=Bandpass(-4000, 4000)), + AnalogMode("lsb", "LSB", bandpass=Bandpass(-3000, -300)), + AnalogMode("usb", "USB", bandpass=Bandpass(300, 3000)), + AnalogMode("cw", "CW", bandpass=Bandpass(700, 900)), + AnalogMode("dmr", "DMR", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_digiham"], squelch=False), + AnalogMode( + "dstar", "D-Star", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_digiham"], squelch=False + ), + AnalogMode("nxdn", "NXDN", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_digiham"], squelch=False), + AnalogMode("ysf", "YSF", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_digiham"], squelch=False), + AnalogMode("m17", "M17", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_m17"], squelch=False), + AnalogMode( + "freedv", "FreeDV", bandpass=Bandpass(300, 3000), requirements=["digital_voice_freedv"], squelch=False + ), + AnalogMode("drm", "DRM", bandpass=Bandpass(-5000, 5000), requirements=["drm"], squelch=False), + DigitalMode("bpsk31", "BPSK31", underlying=["usb"]), + DigitalMode("bpsk63", "BPSK63", underlying=["usb"]), + WsjtMode("ft8", "FT8"), + WsjtMode("ft4", "FT4"), + WsjtMode("jt65", "JT65"), + WsjtMode("jt9", "JT9"), + WsjtMode("wspr", "WSPR", bandpass=Bandpass(1350, 1650)), + WsjtMode("fst4", "FST4", requirements=["wsjt-x-2-3"]), + WsjtMode("fst4w", "FST4W", bandpass=Bandpass(1350, 1650), requirements=["wsjt-x-2-3"]), + WsjtMode("q65", "Q65", requirements=["wsjt-x-2-4"]), + Js8Mode("js8", "JS8Call"), + DigitalMode( + "packet", + "Packet", + underlying=["nfm", "usb", "lsb"], + bandpass=Bandpass(-6250, 6250), + requirements=["packet"], + service=True, + squelch=False, + ), + DigitalMode( + "pocsag", + "Pocsag", + underlying=["nfm"], + bandpass=Bandpass(-6000, 6000), + requirements=["pocsag"], + squelch=False, + ), + ] + + @staticmethod + def getModes(): + return Modes.mappings + + @staticmethod + def getAvailableModes(): + return [m for m in Modes.getModes() if m.is_available()] + + @staticmethod + def getAvailableServices(): + return [m for m in Modes.getAvailableModes() if m.is_service()] + + @staticmethod + def findByModulation(modulation): + modes = [m for m in Modes.getAvailableModes() if m.modulation == modulation] + if modes: + return modes[0] diff --git a/openwebrx/owrx/parser.py b/openwebrx/owrx/parser.py new file mode 100644 index 0000000..2bb75e2 --- /dev/null +++ b/openwebrx/owrx/parser.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from owrx.bands import Bandplan + + +class Parser(ABC): + def __init__(self, handler): + self.handler = handler + self.dial_freq = None + self.band = None + + @abstractmethod + def parse(self, raw): + pass + + def setDialFrequency(self, freq): + self.dial_freq = freq + self.band = Bandplan.getSharedInstance().findBand(freq) + + def getBand(self): + return self.band diff --git a/openwebrx/owrx/pocsag.py b/openwebrx/owrx/pocsag.py new file mode 100644 index 0000000..c265146 --- /dev/null +++ b/openwebrx/owrx/pocsag.py @@ -0,0 +1,17 @@ +from owrx.parser import Parser + +import logging + +logger = logging.getLogger(__name__) + + +class PocsagParser(Parser): + def parse(self, raw): + try: + fields = raw.decode("ascii", "replace").rstrip("\n").split(";") + meta = {v[0]: "".join(v[1:]) for v in map(lambda x: x.split(":"), fields) if v[0] != ""} + if "address" in meta: + meta["address"] = int(meta["address"]) + self.handler.write_pocsag_data(meta) + except Exception: + logger.exception("Exception while parsing Pocsag message") diff --git a/openwebrx/owrx/property/__init__.py b/openwebrx/owrx/property/__init__.py new file mode 100644 index 0000000..7cc84f6 --- /dev/null +++ b/openwebrx/owrx/property/__init__.py @@ -0,0 +1,421 @@ +from abc import ABC, abstractmethod +from owrx.property.validators import Validator +from owrx.property.filter import Filter, ByPropertyName +import logging + +logger = logging.getLogger(__name__) + + +class PropertyError(Exception): + pass + + +class PropertyDeletion(object): + def __bool__(self): + return False + + +# a special object that will be sent in events when a deletion occured +# it can also represent deletion of a key in internal storage, but should not be return from standard dict apis +PropertyDeleted = PropertyDeletion() + + +class Subscription(object): + def __init__(self, subscriptee, name, subscriber): + self.subscriptee = subscriptee + self.name = name + self.subscriber = subscriber + + def getName(self): + return self.name + + def call(self, *args, **kwargs): + self.subscriber(*args, **kwargs) + + def cancel(self): + self.subscriptee.unwire(self) + + +class PropertyManager(ABC): + def __init__(self): + self.subscribers = [] + + @abstractmethod + def __getitem__(self, item): + pass + + @abstractmethod + def __setitem__(self, key, value): + pass + + @abstractmethod + def __contains__(self, item): + pass + + @abstractmethod + def __dict__(self): + pass + + @abstractmethod + def __delitem__(self, key): + pass + + @abstractmethod + def keys(self): + pass + + @abstractmethod + def values(self): + pass + + @abstractmethod + def items(self): + pass + + def __len__(self): + return self.__dict__().__len__() + + def filter(self, *props): + return PropertyFilter(self, ByPropertyName(*props)) + + def readonly(self): + return PropertyReadOnly(self) + + def wire(self, callback): + sub = Subscription(self, None, callback) + self.subscribers.append(sub) + return sub + + def wireProperty(self, name, callback): + sub = Subscription(self, name, callback) + self.subscribers.append(sub) + if name in self: + sub.call(self[name]) + return sub + + def unwire(self, sub): + try: + self.subscribers.remove(sub) + except ValueError: + # happens when already removed before + pass + return self + + def _fireCallbacks(self, changes): + if not changes: + return + subscribers = self.subscribers.copy() + for c in subscribers: + try: + if c.getName() is None: + c.call(changes) + except Exception: + logger.exception("exception while firing changes") + for name in changes: + for c in subscribers: + try: + if c.getName() == name: + c.call(changes[name]) + except Exception: + logger.exception("exception while firing changes") + + +class PropertyLayer(PropertyManager): + def __init__(self, **kwargs): + super().__init__() + # copy, don't re-use + self.properties = {k: v for k, v in kwargs.items()} + + def __contains__(self, name): + return name in self.properties + + def __getitem__(self, name): + return self.properties[name] + + def __setitem__(self, name, value): + if name in self.properties and self.properties[name] == value: + return + self.properties[name] = value + self._fireCallbacks({name: value}) + + def __dict__(self): + return {k: v for k, v in self.properties.items()} + + def __delitem__(self, key): + self.properties.__delitem__(key) + self._fireCallbacks({key: PropertyDeleted}) + + def keys(self): + return self.properties.keys() + + def values(self): + return self.properties.values() + + def items(self): + return self.properties.items() + + +class PropertyFilter(PropertyManager): + def __init__(self, pm: PropertyManager, filter: Filter): + super().__init__() + self.pm = pm + self._filter = filter + self.pm.wire(self.receiveEvent) + + def receiveEvent(self, changes): + changesToForward = {name: value for name, value in changes.items() if self._filter.apply(name)} + self._fireCallbacks(changesToForward) + + def __getitem__(self, item): + if not self._filter.apply(item): + raise KeyError(item) + return self.pm.__getitem__(item) + + def __setitem__(self, key, value): + if not self._filter.apply(key): + raise KeyError(key) + return self.pm.__setitem__(key, value) + + def __contains__(self, item): + if not self._filter.apply(item): + return False + return self.pm.__contains__(item) + + def __dict__(self): + return {k: v for k, v in self.pm.__dict__().items() if self._filter.apply(k)} + + def __delitem__(self, key): + if not self._filter.apply(key): + raise KeyError(key) + return self.pm.__delitem__(key) + + def keys(self): + return [k for k in self.pm.keys() if self._filter.apply(k)] + + def values(self): + return [v for k, v in self.pm.items() if self._filter.apply(k)] + + def items(self): + return self.__dict__().items() + + +class PropertyDelegator(PropertyManager): + def __init__(self, pm: PropertyManager): + self.pm = pm + self.subscription = self.pm.wire(self._fireCallbacks) + super().__init__() + + def __getitem__(self, item): + return self.pm.__getitem__(item) + + def __setitem__(self, key, value): + return self.pm.__setitem__(key, value) + + def __contains__(self, item): + return self.pm.__contains__(item) + + def __dict__(self): + return self.pm.__dict__() + + def __delitem__(self, key): + return self.pm.__delitem__(key) + + def keys(self): + return self.pm.keys() + + def values(self): + return self.pm.values() + + def items(self): + return self.pm.items() + + +class PropertyValidationError(PropertyError): + def __init__(self, key, value): + super().__init__('Invalid value for property "{key}": "{value}"'.format(key=key, value=str(value))) + + +class PropertyValidator(PropertyDelegator): + def __init__(self, pm: PropertyManager, validators=None): + super().__init__(pm) + if validators is None: + self.validators = {} + else: + self.validators = {k: Validator.of(v) for k, v in validators.items()} + + def validate(self, key, value): + if key not in self.validators: + return + if not self.validators[key].isValid(value): + raise PropertyValidationError(key, value) + + def setValidator(self, key, validator): + self.validators[key] = Validator.of(validator) + + def __setitem__(self, key, value): + self.validate(key, value) + return self.pm.__setitem__(key, value) + + +class PropertyWriteError(PropertyError): + def __init__(self, key): + super().__init__('Key "{key}" is not writeable'.format(key=key)) + + +class PropertyReadOnly(PropertyDelegator): + def __setitem__(self, key, value): + raise PropertyWriteError(key) + + def __delitem__(self, key): + raise PropertyWriteError(key) + + +class PropertyStack(PropertyManager): + def __init__(self): + super().__init__() + self.layers = [] + + def addLayer(self, priority: int, pm: PropertyManager): + """ + highest priority = 0 + """ + self._fireCallbacks(self._addLayer(priority, pm)) + + def _addLayer(self, priority: int, pm: PropertyManager): + changes = {} + for key in pm.keys(): + if key not in self or self[key] != pm[key]: + changes[key] = pm[key] + + def eventClosure(changes): + self.receiveEvent(pm, changes) + + sub = pm.wire(eventClosure) + + self.layers.append({"priority": priority, "props": pm, "sub": sub}) + + return changes + + def removeLayerByPriority(self, priority): + for layer in self.layers: + if layer["priority"] == priority: + self.removeLayer(layer["props"]) + + def removeLayer(self, pm: PropertyManager): + for layer in self.layers: + if layer["props"] == pm: + self._fireCallbacks(self._removeLayer(layer)) + + def _removeLayer(self, layer): + layer["sub"].cancel() + self.layers.remove(layer) + changes = {} + pm = layer["props"] + for key in pm.keys(): + if key in self: + if self[key] != pm[key]: + changes[key] = self[key] + else: + changes[key] = PropertyDeleted + return changes + + def replaceLayer(self, priority: int, pm: PropertyManager): + layers = [x for x in self.layers if x["priority"] == priority] + + originalState = self.__dict__() + + changes = self._removeLayer(layers[0]) if layers else {} + changes = {**changes, **self._addLayer(priority, pm)} + changes = {k: v for k, v in changes.items() if k not in originalState or originalState[k] != v} + + self._fireCallbacks(changes) + + def receiveEvent(self, layer, changes): + changesToForward = {name: value for name, value in changes.items() if layer == self._getTopLayer(name)} + # deletions need to be handled separately: + # * send a deletion if the key was deleted in all layers + # * send lower value if the key is still present in a lower layer + deletionsToForward = { + name: PropertyDeleted if self._getTopLayer(name, False) is None else self[name] + for name, value in changes.items() + if value is PropertyDeleted + } + self._fireCallbacks({**changesToForward, **deletionsToForward}) + + def _getTopLayer(self, item, fallback=True): + layers = [la["props"] for la in sorted(self.layers, key=lambda l: l["priority"])] + for m in layers: + if item in m: + return m + # return top layer as fallback + if fallback and layers: + return layers[0] + + def __getitem__(self, item): + layer = self._getTopLayer(item) + return layer.__getitem__(item) + + def __setitem__(self, key, value): + layer = self._getTopLayer(key) + return layer.__setitem__(key, value) + + def __contains__(self, item): + layer = self._getTopLayer(item) + if layer: + return layer.__contains__(item) + return False + + def __dict__(self): + return {k: self.__getitem__(k) for k in self.keys()} + + def __delitem__(self, key): + for layer in self.layers: + if layer["props"].__contains__(key): + layer["props"].__delitem__(key) + + def keys(self): + return set([key for l in self.layers for key in l["props"].keys()]) + + def values(self): + return [self.__getitem__(k) for k in self.keys()] + + def items(self): + return self.__dict__().items() + + +class PropertyCarousel(PropertyDelegator): + def __init__(self): + # start with an empty dummy layer + self.emptyLayer = PropertyLayer().readonly() + super().__init__(self.emptyLayer) + self.layers = {} + + def _getDefaultLayer(self): + return self.emptyLayer + + def addLayer(self, key, value): + if key in self.layers and self.layers[key] is self.pm: + self.layers[key] = value + # switch after introducing the new value + self.switch(key) + else: + self.layers[key] = value + + def removeLayer(self, key): + if key in self.layers and self.layers[key] is self.pm: + self.switch() + del self.layers[key] + + def switch(self, key=None): + before = self.pm + self.subscription.cancel() + self.pm = self._getDefaultLayer() if key is None else self.layers[key] + self.subscription = self.pm.wire(self._fireCallbacks) + changes = {} + for key in set(list(before.keys()) + list(self.keys())): + if key not in self: + changes[key] = PropertyDeleted + else: + if key not in before or before[key] != self[key]: + changes[key] = self[key] + self._fireCallbacks(changes) diff --git a/openwebrx/owrx/property/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/property/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..3e3d76d Binary files /dev/null and b/openwebrx/owrx/property/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/property/__pycache__/filter.cpython-37.pyc b/openwebrx/owrx/property/__pycache__/filter.cpython-37.pyc new file mode 100644 index 0000000..6ab99a4 Binary files /dev/null and b/openwebrx/owrx/property/__pycache__/filter.cpython-37.pyc differ diff --git a/openwebrx/owrx/property/__pycache__/validators.cpython-37.pyc b/openwebrx/owrx/property/__pycache__/validators.cpython-37.pyc new file mode 100644 index 0000000..1bdbe12 Binary files /dev/null and b/openwebrx/owrx/property/__pycache__/validators.cpython-37.pyc differ diff --git a/openwebrx/owrx/property/filter.py b/openwebrx/owrx/property/filter.py new file mode 100644 index 0000000..7e870d0 --- /dev/null +++ b/openwebrx/owrx/property/filter.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod + + +class Filter(ABC): + @abstractmethod + def apply(self, prop) -> bool: + pass + + +class ByPropertyName(Filter): + def __init__(self, *props): + self.props = props + + def apply(self, prop) -> bool: + return prop in self.props + + +class ByLambda(Filter): + def __init__(self, func): + self.func = func + + def apply(self, prop) -> bool: + return self.func(prop) diff --git a/openwebrx/owrx/property/validators.py b/openwebrx/owrx/property/validators.py new file mode 100644 index 0000000..f1c197e --- /dev/null +++ b/openwebrx/owrx/property/validators.py @@ -0,0 +1,97 @@ +from abc import ABC, abstractmethod +from functools import reduce +from operator import or_ + + +class ValidatorException(Exception): + pass + + +class Validator(ABC): + @staticmethod + def of(x): + if isinstance(x, Validator): + return x + if callable(x): + return LambdaValidator(x) + if x in validator_types: + return validator_types[x]() + raise ValidatorException("Cannot create validator") + + @abstractmethod + def isValid(self, value): + pass + + +class LambdaValidator(Validator): + def __init__(self, c): + self.callable = c + + def isValid(self, value): + return self.callable(value) + + +class TypeValidator(Validator): + def __init__(self, type): + self.type = type + super().__init__() + + def isValid(self, value): + return isinstance(value, self.type) + + +class IntegerValidator(TypeValidator): + def __init__(self): + super().__init__(int) + + +class FloatValidator(TypeValidator): + def __init__(self): + super().__init__(float) + + +class StringValidator(TypeValidator): + def __init__(self): + super().__init__(str) + + +class BoolValidator(TypeValidator): + def __init__(self): + super().__init__(bool) + + +class OrValidator(Validator): + def __init__(self, *validators): + self.validators = validators + super().__init__() + + def isValid(self, value): + return reduce( + or_, + [v.isValid(value) for v in self.validators], + False + ) + + +class NumberValidator(OrValidator): + def __init__(self): + super().__init__(IntegerValidator(), FloatValidator()) + + +class RegexValidator(StringValidator): + def __init__(self, regex): + self.regex = regex + super().__init__() + + def isValid(self, value): + return super().isValid(value) and self.regex.match(value) is not None + + +validator_types = { + "string": StringValidator, + "str": StringValidator, + "integer": IntegerValidator, + "int": IntegerValidator, + "number": NumberValidator, + "num": NumberValidator, +} diff --git a/openwebrx/owrx/receiverid.py b/openwebrx/owrx/receiverid.py new file mode 100644 index 0000000..e21760a --- /dev/null +++ b/openwebrx/owrx/receiverid.py @@ -0,0 +1,98 @@ +import re +import logging +import hashlib +import hmac +from datetime import datetime, timezone +from owrx.config import Config + +logger = logging.getLogger(__name__) + + +keyRegex = re.compile("^([a-zA-Z]+)-([0-9a-f]{32})-([0-9a-f]{64})$") +keyChallengeRegex = re.compile("^([a-zA-Z]+)-([0-9a-f]{32})-([0-9a-f]{32})$") +headerRegex = re.compile("^ReceiverId (.*)$") + + +class KeyException(Exception): + pass + + +class Key(object): + def __init__(self, keyString): + matches = keyRegex.match(keyString) + if not matches: + raise KeyException("invalid key format") + self.source = matches.group(1) + self.id = matches.group(2) + self.secret = matches.group(3) + + +class KeyChallenge(object): + def __init__(self, challengeString): + matches = keyChallengeRegex.match(challengeString) + if not matches: + raise KeyException("invalid key challenge format") + self.source = matches.group(1) + self.id = matches.group(2) + self.challenge = matches.group(3) + + +class KeyResponse(object): + def __init__(self, source, id, time, signature): + self.source = source + self.id = id + self.time = time + self.signature = signature + + def __str__(self): + return "{source}-{id}-{time}-{signature}".format( + source=self.source, + id=self.id, + time=self.time, + signature=self.signature, + ) + + +class ReceiverId(object): + @staticmethod + def getResponseHeader(requestHeader): + matches = headerRegex.match(requestHeader) + if not matches: + raise KeyException("invalid authorization header") + challenges = [KeyChallenge(i) for i in matches.group(1).split(",")] + + def signChallenge(challenge): + key = ReceiverId.findKey(challenge) + if key is None: + return + return ReceiverId.signChallenge(challenge, key) + + responses = [signChallenge(c) for c in challenges] + return ",".join(str(r) for r in responses if r is not None) + + @staticmethod + def findKey(challenge): + def parseKey(keyString): + try: + return Key(keyString) + except KeyException as e: + logger.error(e) + + config = Config.get() + if "receiver_keys" not in config or config["receiver_keys"] is None: + return None + keys = [parseKey(keyString) for keyString in config["receiver_keys"]] + keys = [key for key in keys if key is not None] + matching_keys = [key for key in keys if key.source == challenge.source and key.id == challenge.id] + if matching_keys: + return matching_keys[0] + return None + + @staticmethod + def signChallenge(challenge, key): + now = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc) + now_bytes = int(now.timestamp()).to_bytes(4, byteorder="big") + m = hmac.new(bytes.fromhex(key.secret), digestmod=hashlib.sha256) + m.update(bytes.fromhex(challenge.challenge)) + m.update(now_bytes) + return KeyResponse(challenge.source, challenge.id, now_bytes.hex(), m.hexdigest()) diff --git a/openwebrx/owrx/reporting/__init__.py b/openwebrx/owrx/reporting/__init__.py new file mode 100644 index 0000000..f65feab --- /dev/null +++ b/openwebrx/owrx/reporting/__init__.py @@ -0,0 +1,57 @@ +import threading +from owrx.config import Config +from owrx.reporting.reporter import Reporter +from owrx.reporting.pskreporter import PskReporter +from owrx.reporting.wsprnet import WsprnetReporter +import logging + +logger = logging.getLogger(__name__) + + +class ReportingEngine(object): + creationLock = threading.Lock() + sharedInstance = None + + reporterClasses = { + "pskreporter_enabled": PskReporter, + "wsprnet_enabled": WsprnetReporter, + } + + @staticmethod + def getSharedInstance(): + with ReportingEngine.creationLock: + if ReportingEngine.sharedInstance is None: + ReportingEngine.sharedInstance = ReportingEngine() + return ReportingEngine.sharedInstance + + @staticmethod + def stopAll(): + with ReportingEngine.creationLock: + if ReportingEngine.sharedInstance is not None: + ReportingEngine.sharedInstance.stop() + + def __init__(self): + self.reporters = [] + self.configSub = Config.get().filter(*ReportingEngine.reporterClasses.keys()).wire(self.setupReporters) + self.setupReporters() + + def setupReporters(self, *args): + config = Config.get() + for configKey, reporterClass in ReportingEngine.reporterClasses.items(): + if configKey in config and config[configKey]: + if not any(isinstance(r, reporterClass) for r in self.reporters): + self.reporters += [reporterClass()] + else: + for reporter in [r for r in self.reporters if isinstance(r, reporterClass)]: + reporter.stop() + self.reporters.remove(reporter) + + def stop(self): + for r in self.reporters: + r.stop() + self.configSub.cancel() + + def spot(self, spot): + for r in self.reporters: + if spot["mode"] in r.getSupportedModes(): + r.spot(spot) diff --git a/openwebrx/owrx/reporting/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/reporting/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..a9f6cc6 Binary files /dev/null and b/openwebrx/owrx/reporting/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/reporting/__pycache__/pskreporter.cpython-37.pyc b/openwebrx/owrx/reporting/__pycache__/pskreporter.cpython-37.pyc new file mode 100644 index 0000000..9c42af9 Binary files /dev/null and b/openwebrx/owrx/reporting/__pycache__/pskreporter.cpython-37.pyc differ diff --git a/openwebrx/owrx/reporting/__pycache__/reporter.cpython-37.pyc b/openwebrx/owrx/reporting/__pycache__/reporter.cpython-37.pyc new file mode 100644 index 0000000..bfa958e Binary files /dev/null and b/openwebrx/owrx/reporting/__pycache__/reporter.cpython-37.pyc differ diff --git a/openwebrx/owrx/reporting/__pycache__/wsprnet.cpython-37.pyc b/openwebrx/owrx/reporting/__pycache__/wsprnet.cpython-37.pyc new file mode 100644 index 0000000..1e38b8f Binary files /dev/null and b/openwebrx/owrx/reporting/__pycache__/wsprnet.cpython-37.pyc differ diff --git a/openwebrx/owrx/reporting/pskreporter.py b/openwebrx/owrx/reporting/pskreporter.py new file mode 100644 index 0000000..aa88be3 --- /dev/null +++ b/openwebrx/owrx/reporting/pskreporter.py @@ -0,0 +1,222 @@ +import logging +import threading +import time +import random +import socket +from functools import reduce +from operator import and_ +from owrx.config import Config +from owrx.version import openwebrx_version +from owrx.locator import Locator +from owrx.metrics import Metrics, CounterMetric +from owrx.reporting.reporter import Reporter + +logger = logging.getLogger(__name__) + + +class PskReporter(Reporter): + interval = 300 + + def getSupportedModes(self): + return ["FT8", "FT4", "JT9", "JT65", "FST4", "JS8", "Q65"] + + def stop(self): + self.cancelTimer() + with self.spotLock: + self.spots = [] + + def __init__(self): + self.spots = [] + self.spotLock = threading.Lock() + self.uploader = Uploader() + self.timer = None + metrics = Metrics.getSharedInstance() + self.dupeCounter = CounterMetric() + metrics.addMetric("pskreporter.duplicates", self.dupeCounter) + self.spotCounter = CounterMetric() + metrics.addMetric("pskreporter.spots", self.spotCounter) + + def scheduleNextUpload(self): + if self.timer: + return + delay = PskReporter.interval + random.uniform(0, 30) + logger.debug("scheduling next pskreporter upload in %f seconds", delay) + self.timer = threading.Timer(delay, self.upload) + self.timer.start() + + def spotEquals(self, s1, s2): + keys = ["callsign", "timestamp", "locator", "mode", "msg"] + + return reduce(and_, map(lambda key: s1[key] == s2[key], keys)) + + def spot(self, spot): + with self.spotLock: + if any(x for x in self.spots if self.spotEquals(spot, x)): + # dupe + self.dupeCounter.inc() + else: + self.spotCounter.inc() + self.spots.append(spot) + self.scheduleNextUpload() + + def upload(self): + try: + with self.spotLock: + self.timer = None + spots = self.spots + self.spots = [] + + if spots: + self.uploader.upload(spots) + except Exception: + logger.exception("Failed to upload spots") + + def cancelTimer(self): + if self.timer: + self.timer.cancel() + + +class Uploader(object): + receieverDelimiter = [0x99, 0x92] + senderDelimiter = [0x99, 0x93] + + def __init__(self): + self.sequence = 0 + self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + def upload(self, spots): + logger.debug("uploading %i spots", len(spots)) + for packet in self.getPackets(spots): + self.socket.sendto(packet, ("report.pskreporter.info", 4739)) + + def getPackets(self, spots): + encoded = [self.encodeSpot(spot) for spot in spots] + # filter out any erroneous encodes + encoded = [e for e in encoded if e is not None] + + def chunks(l, n): + """Yield successive n-sized chunks from l.""" + for i in range(0, len(l), n): + yield l[i : i + n] + + rHeader = self.getReceiverInformationHeader() + rInfo = self.getReceiverInformation() + sHeader = self.getSenderInformationHeader() + + packets = [] + # 50 seems to be a safe bet + for chunk in chunks(encoded, 50): + sInfo = self.getSenderInformation(chunk) + length = 16 + len(rHeader) + len(sHeader) + len(rInfo) + len(sInfo) + header = self.getHeader(length) + packets.append(header + rHeader + sHeader + rInfo + sInfo) + + return packets + + def getHeader(self, length): + self.sequence += 1 + return bytes( + # protocol version + [0x00, 0x0A] + + list(length.to_bytes(2, "big")) + + list(int(time.time()).to_bytes(4, "big")) + + list(self.sequence.to_bytes(4, "big")) + + list((id(self) & 0xFFFFFFFF).to_bytes(4, "big")) + ) + + def encodeString(self, s): + return [len(s)] + list(s.encode("utf-8")) + + def encodeSpot(self, spot): + try: + return bytes( + self.encodeString(spot["callsign"]) + + 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"]) + # informationsource. 1 means "automatically extracted + + [0x01] + + list(int(spot["timestamp"] / 1000).to_bytes(4, "big")) + ) + except Exception: + logger.exception("Error while encoding spot for pskreporter") + return None + + def getReceiverInformationHeader(self): + pm = Config.get() + with_antenna = "pskreporter_antenna_information" in pm and pm["pskreporter_antenna_information"] is not None + num_fields = 4 if with_antenna else 3 + length = 12 + num_fields * 8 + return bytes( + # id + [0x00, 0x03] + # length + + list(length.to_bytes(2, "big")) + + Uploader.receieverDelimiter + # number of fields + + list(num_fields.to_bytes(2, "big")) + # padding + + [0x00, 0x00] + # receiverCallsign + + [0x80, 0x02, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F] + # receiverLocator + + [0x80, 0x04, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F] + # decodingSoftware + + [0x80, 0x08, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F] + # antennaInformation + + ([0x80, 0x09, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F] if with_antenna else []) + # padding + + [0x00, 0x00] + ) + + def getReceiverInformation(self): + pm = Config.get() + bodyFields = [ + # callsign + pm["pskreporter_callsign"], + # locator + Locator.fromCoordinates(pm["receiver_gps"]), + # decodingSoftware + "OpenWebRX " + openwebrx_version, + ] + if "pskreporter_antenna_information" in pm and pm["pskreporter_antenna_information"] is not None: + bodyFields += [pm["pskreporter_antenna_information"]] + body = [b for s in bodyFields for b in self.encodeString(s)] + body = self.pad(body, 4) + body = bytes(Uploader.receieverDelimiter + list((len(body) + 4).to_bytes(2, "big")) + body) + return body + + def getSenderInformationHeader(self): + return bytes( + # id, length + [0x00, 0x02, 0x00, 0x3C] + + Uploader.senderDelimiter + # number of fields + + [0x00, 0x07] + # senderCallsign + + [0x80, 0x01, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F] + # frequency + + [0x80, 0x05, 0x00, 0x04, 0x00, 0x00, 0x76, 0x8F] + # sNR + + [0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x76, 0x8F] + # mode + + [0x80, 0x0A, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F] + # senderLocator + + [0x80, 0x03, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F] + # informationSource + + [0x80, 0x0B, 0x00, 0x01, 0x00, 0x00, 0x76, 0x8F] + # flowStartSeconds + + [0x00, 0x96, 0x00, 0x04] + ) + + def getSenderInformation(self, chunk): + sInfo = self.padBytes(b"".join(chunk), 4) + sInfoLength = len(sInfo) + 4 + return bytes(Uploader.senderDelimiter) + sInfoLength.to_bytes(2, "big") + sInfo + + def pad(self, b, l): + return b + [0x00 for _ in range(0, -1 * len(b) % l)] + + def padBytes(self, b, l): + return b + bytes([0x00 for _ in range(0, -1 * len(b) % l)]) diff --git a/openwebrx/owrx/reporting/reporter.py b/openwebrx/owrx/reporting/reporter.py new file mode 100644 index 0000000..5ccb741 --- /dev/null +++ b/openwebrx/owrx/reporting/reporter.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod + + +class Reporter(ABC): + @abstractmethod + def stop(self): + pass + + @abstractmethod + def spot(self, spot): + pass + + @abstractmethod + def getSupportedModes(self): + return [] diff --git a/openwebrx/owrx/reporting/wsprnet.py b/openwebrx/owrx/reporting/wsprnet.py new file mode 100644 index 0000000..e744bd4 --- /dev/null +++ b/openwebrx/owrx/reporting/wsprnet.py @@ -0,0 +1,97 @@ +from owrx.reporting.reporter import Reporter +from owrx.version import openwebrx_version +from owrx.config import Config +from owrx.locator import Locator +from owrx.metrics import Metrics, CounterMetric +from queue import Queue, Full +from urllib import request, parse +import threading +import logging +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +PoisonPill = object() + + +class Worker(threading.Thread): + def __init__(self, queue: Queue): + self.queue = queue + self.doRun = True + + super().__init__(daemon=True) + + def run(self): + while self.doRun: + try: + spot = self.queue.get() + if spot is PoisonPill: + self.doRun = False + else: + self.uploadSpot(spot) + self.queue.task_done() + except Exception: + logger.exception("Exception while uploading WSPRNet spot") + + def _getMode(self, spot): + interval = round(spot["interval"] / 60) + # FST4W modes are mapped not to conflict with WSPR modes 2 and 15: + if spot["mode"] != "WSPR" and interval in [2, 15]: + return interval + 1 + return interval + + def uploadSpot(self, spot): + config = Config.get() + # function=wspr&date=210114&time=1732&sig=-15&dt=0.5&drift=0&tqrg=7.040019&tcall=DF2UU&tgrid=JN48&dbm=37&version=2.3.0-rc3&rcall=DD5JFK&rgrid=JN58SC&rqrg=7.040047&mode=2 + # {'timestamp': 1610655960000, 'db': -23.0, 'dt': 0.3, 'freq': 7040048, 'drift': -1, 'msg': 'LA3JJ JO59 37', 'callsign': 'LA3JJ', 'locator': 'JO59', 'mode': 'WSPR'} + date = datetime.fromtimestamp(spot["timestamp"] / 1000, tz=timezone.utc) + data = parse.urlencode( + { + "function": "wspr", + "date": date.strftime("%y%m%d"), + "time": date.strftime("%H%M"), + "sig": spot["db"], + "dt": spot["dt"], + # FST4W does not have drift + "drift": spot["drift"] if "drift" in spot else 0, + "tqrg": spot["freq"] / 1e6, + "tcall": spot["callsign"], + "tgrid": spot["locator"], + "dbm": spot["dbm"], + "version": openwebrx_version, + "rcall": config["wsprnet_callsign"], + "rgrid": Locator.fromCoordinates(config["receiver_gps"]), + "mode": self._getMode(spot), + } + ).encode() + request.urlopen("http://wsprnet.org/post/", data, timeout=60) + + +class WsprnetReporter(Reporter): + def __init__(self): + # max 100 entries + self.queue = Queue(100) + # single worker + Worker(self.queue).start() + + # metrics + metrics = Metrics.getSharedInstance() + self.spotCounter = CounterMetric() + metrics.addMetric("wsprnet.spots", self.spotCounter) + + def stop(self): + while not self.queue.empty(): + self.queue.get(timeout=1) + self.queue.task_done() + self.queue.put(PoisonPill) + + def spot(self, spot): + try: + self.queue.put(spot, block=False) + self.spotCounter.inc() + except Full: + logger.warning("WSPRNet Queue overflow, one spot lost") + + def getSupportedModes(self): + return ["WSPR", "FST4W"] diff --git a/openwebrx/owrx/sdr.py b/openwebrx/owrx/sdr.py new file mode 100644 index 0000000..503fe5c --- /dev/null +++ b/openwebrx/owrx/sdr.py @@ -0,0 +1,261 @@ +from owrx.config import Config +from owrx.property import PropertyManager, PropertyDeleted, PropertyDelegator, PropertyLayer, PropertyReadOnly +from owrx.feature import FeatureDetector, UnknownFeatureException +from owrx.source import SdrSource, SdrSourceEventClient +from functools import partial + +import logging + +logger = logging.getLogger(__name__) + + +class MappedSdrSources(PropertyDelegator): + def __init__(self, pm: PropertyManager): + self.subscriptions = {} + super().__init__(PropertyLayer()) + for key, value in pm.items(): + self._addSource(key, value) + pm.wire(self.handleSdrDeviceChange) + + def handleSdrDeviceChange(self, changes): + for key, value in changes.items(): + if value is PropertyDeleted: + if key in self: + del self[key] + else: + if key not in self: + self._addSource(key, value) + + def handleDeviceUpdate(self, key, value, *args): + if key not in self and self.isDeviceValid(value): + self[key] = self.buildNewSource(key, value) + elif key in self and not self.isDeviceValid(value): + del self[key] + + def _addSource(self, key, value): + self.handleDeviceUpdate(key, value) + updateMethod = partial(self.handleDeviceUpdate, key, value) + self.subscriptions[key] = [ + value.filter("type", "profiles").wire(updateMethod), + value["profiles"].wire(updateMethod) + ] + + def isDeviceValid(self, device): + return self._sdrTypeAvailable(device) and self._hasProfiles(device) + + def _hasProfiles(self, device): + return "profiles" in device and device["profiles"] and len(device["profiles"]) > 0 + + def _sdrTypeAvailable(self, value): + featureDetector = FeatureDetector() + try: + if not featureDetector.is_available(value["type"]): + logger.error( + 'The SDR source type "{0}" is not available. please check the feature report for details.'.format( + value["type"] + ) + ) + return False + return True + except UnknownFeatureException: + logger.error( + 'The SDR source type "{0}" is invalid. Please check your configuration'.format(value["type"]) + ) + return False + + def buildNewSource(self, id, props): + sdrType = props["type"] + className = "".join(x for x in sdrType.title() if x.isalnum()) + "Source" + module = __import__("owrx.source.{0}".format(sdrType), fromlist=[className]) + cls = getattr(module, className) + return cls(id, props) + + def _removeSource(self, key, source): + source.shutdown() + for sub in self.subscriptions[key]: + sub.cancel() + del self.subscriptions[key] + + def __setitem__(self, key, value): + source = self[key] if key in self else None + if source is value: + return + super().__setitem__(key, value) + if source is not None: + self._removeSource(key, source) + + def __delitem__(self, key): + source = self[key] if key in self else None + super().__delitem__(key) + if source is not None: + self._removeSource(key, source) + + +class SourceStateHandler(SdrSourceEventClient): + def __init__(self, pm, key, source: SdrSource): + self.pm = pm + self.key = key + self.source = source + + def selfDestruct(self): + self.source.removeClient(self) + + def onFail(self): + del self.pm[self.key] + + def onDisable(self): + del self.pm[self.key] + + def onEnable(self): + self.pm[self.key] = self.source + + def onShutdown(self): + del self.pm[self.key] + + +class ActiveSdrSources(PropertyReadOnly): + def __init__(self, pm: PropertyManager): + self.handlers = {} + self._layer = PropertyLayer() + super().__init__(self._layer) + for key, value in pm.items(): + self._addSource(key, value) + pm.wire(self.handleSdrDeviceChange) + + def handleSdrDeviceChange(self, changes): + for key, value in changes.items(): + if value is PropertyDeleted: + self._removeSource(key) + else: + self._addSource(key, value) + + def isAvailable(self, source: SdrSource): + return source.isEnabled() and not source.isFailed() + + def _addSource(self, key, source: SdrSource): + if self.isAvailable(source): + self._layer[key] = source + self.handlers[key] = SourceStateHandler(self._layer, key, source) + source.addClient(self.handlers[key]) + + def _removeSource(self, key): + self.handlers[key].selfDestruct() + del self.handlers[key] + if key in self._layer: + del self._layer[key] + + +class AvailableProfiles(PropertyReadOnly): + def __init__(self, pm: PropertyManager): + self.subscriptions = {} + self.profileSubscriptions = {} + self._layer = PropertyLayer() + super().__init__(self._layer) + for key, value in pm.items(): + self._addSource(key, value) + pm.wire(self.handleSdrDeviceChange) + + def handleSdrDeviceChange(self, changes): + for key, value in changes.items(): + if value is PropertyDeleted: + self._removeSource(key) + else: + self._addSource(key, value) + + def handleSdrNameChange(self, s_id, source, name): + profiles = source.getProfiles() + for p_id in list(self._layer.keys()): + source_id, profile_id = p_id.split("|") + if source_id == s_id: + profile = profiles[profile_id] + self._layer[p_id] = "{} {}".format(name, profile["name"]) + + def handleProfileChange(self, source_id, source: SdrSource, changes): + for key, value in changes.items(): + if value is PropertyDeleted: + self._removeProfile(source_id, key) + else: + self._addProfile(source_id, source, key, value) + + def handleProfileNameChange(self, s_id, source: SdrSource, p_id, name): + for concat_p_id in list(self._layer.keys()): + source_id, profile_id = concat_p_id.split("|") + if source_id == s_id and profile_id == p_id: + self._layer[concat_p_id] = "{} {}".format(source.getName(), name) + + def _addSource(self, key, source: SdrSource): + for p_id, p in source.getProfiles().items(): + self._addProfile(key, source, p_id, p) + self.subscriptions[key] = [ + source.getProps().wireProperty("name", partial(self.handleSdrNameChange, key, source)), + source.getProfiles().wire(partial(self.handleProfileChange, key, source)), + ] + + def _addProfile(self, s_id, source: SdrSource, p_id, profile): + self._layer["{}|{}".format(s_id, p_id)] = "{} {}".format(source.getName(), profile["name"]) + if s_id not in self.profileSubscriptions: + self.profileSubscriptions[s_id] = {} + self.profileSubscriptions[s_id][p_id] = profile.wireProperty("name", partial(self.handleProfileNameChange, s_id, source, p_id)) + + def _removeSource(self, key): + for profile_id in list(self._layer.keys()): + if profile_id.startswith("{}|".format(key)): + del self._layer[profile_id] + if key in self.subscriptions: + while self.subscriptions[key]: + self.subscriptions[key].pop().cancel() + del self.subscriptions[key] + if key in self.profileSubscriptions: + for p_id in self.profileSubscriptions[key].keys(): + self.profileSubscriptions[key][p_id].cancel() + del self.profileSubscriptions[key] + + def _removeProfile(self, s_id, p_id): + for concat_p_id in list(self._layer.keys()): + source_id, profile_id = concat_p_id.split("|") + if source_id == s_id and profile_id == p_id: + del self._layer[concat_p_id] + if s_id in self.profileSubscriptions and p_id in self.profileSubscriptions[s_id]: + self.profileSubscriptions[s_id][p_id].cancel() + del self.profileSubscriptions[s_id][p_id] + + +class SdrService(object): + sources = None + activeSources = None + availableProfiles = None + + @staticmethod + def getFirstSource(): + sources = SdrService.getActiveSources() + if not sources: + return None + # TODO: configure default sdr in config? right now it will pick the first one off the list. + return sources[list(sources.keys())[0]] + + @staticmethod + def getSource(id): + sources = SdrService.getActiveSources() + if not sources: + return None + if id not in sources: + return None + return sources[id] + + @staticmethod + def getAllSources(): + if SdrService.sources is None: + SdrService.sources = MappedSdrSources(Config.get()["sdrs"]) + return SdrService.sources + + @staticmethod + def getActiveSources(): + if SdrService.activeSources is None: + SdrService.activeSources = ActiveSdrSources(SdrService.getAllSources()) + return SdrService.activeSources + + @staticmethod + def getAvailableProfiles(): + if SdrService.availableProfiles is None: + SdrService.availableProfiles = AvailableProfiles(SdrService.getActiveSources()) + return SdrService.availableProfiles diff --git a/openwebrx/owrx/service/__init__.py b/openwebrx/owrx/service/__init__.py new file mode 100644 index 0000000..2206924 --- /dev/null +++ b/openwebrx/owrx/service/__init__.py @@ -0,0 +1,374 @@ +import threading +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass +from owrx.sdr import SdrService +from owrx.bands import Bandplan +from csdr.output import Output +from csdr import Dsp +from owrx.wsjt import WsjtParser +from owrx.aprs import AprsParser +from owrx.js8 import Js8Parser +from owrx.config.core import CoreConfig +from owrx.config import Config +from owrx.source.resampler import Resampler +from owrx.property import PropertyLayer, PropertyDeleted +from js8py import Js8Frame +from abc import ABCMeta, abstractmethod +from owrx.service.schedule import ServiceScheduler +from owrx.modes import Modes + +import logging + +logger = logging.getLogger(__name__) + + +class ServiceOutput(Output, metaclass=ABCMeta): + def __init__(self, frequency): + self.frequency = frequency + + @abstractmethod + def getParser(self): + # abstract method; implement in subclasses + pass + + def receive_output(self, t, read_fn): + parser = self.getParser() + parser.setDialFrequency(self.frequency) + target = self.pump(read_fn, parser.parse) + threading.Thread(target=target, name="service_output_receive").start() + + +class WsjtServiceOutput(ServiceOutput): + def getParser(self): + return WsjtParser(WsjtHandler()) + + def supports_type(self, t): + return t == "wsjt_demod" + + +class AprsServiceOutput(ServiceOutput): + def getParser(self): + return AprsParser(AprsHandler()) + + def supports_type(self, t): + return t == "packet_demod" + + +class Js8ServiceOutput(ServiceOutput): + def getParser(self): + return Js8Parser(Js8Handler()) + + def supports_type(self, t): + return t == "js8_demod" + + +class ServiceHandler(SdrSourceEventClient): + def __init__(self, source): + self.lock = threading.RLock() + self.services = [] + self.source = source + self.startupTimer = None + self.activitySub = None + self.running = False + props = self.source.getProps() + self.enabledSub = props.wireProperty("services", self._receiveEvent) + self.decodersSub = None + # need to call _start() manually if property is not set since the default is True, but the initial call is only + # made if the property is present + if "services" not in props: + self._start() + + def _receiveEvent(self, state): + # deletion means fall back to default, which is True + if state is PropertyDeleted: + state = True + if self.running == state: + return + if state: + self._start() + else: + self._stop() + + def _start(self): + self.running = True + self.source.addClient(self) + props = self.source.getProps() + self.activitySub = props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange) + self.decodersSub = Config.get().wireProperty("services_decoders", self.onFrequencyChange) + if self.source.isAvailable(): + self._scheduleServiceStartup() + + def _stop(self): + if self.activitySub is not None: + self.activitySub.cancel() + self.activitySub = None + if self.decodersSub is not None: + self.decodersSub.cancel() + self.decodersSub = None + self._cancelStartupTimer() + self.source.removeClient(self) + self.stopServices() + self.running = False + + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.INACTIVE + + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.RUNNING: + self._scheduleServiceStartup() + elif state is SdrSourceState.STOPPING: + logger.debug("sdr source becoming unavailable; stopping services.") + self.stopServices() + + def onFail(self): + logger.debug("sdr source failed; stopping services.") + self.stopServices() + + def onShutdown(self): + logger.debug("sdr source is shutting down; shutting down service handler, too.") + self.shutdown() + + def onEnable(self): + self._scheduleServiceStartup() + + def isSupported(self, mode): + configured = Config.get()["services_decoders"] + available = [m.modulation for m in Modes.getAvailableServices()] + return mode in configured and mode in available + + def shutdown(self): + self._stop() + if self.enabledSub is not None: + self.enabledSub.cancel() + self.enabledSub = None + + def stopServices(self): + with self.lock: + services = self.services + self.services = [] + + for service in services: + service.stop() + + def onFrequencyChange(self, changes): + self.stopServices() + if not self.source.isAvailable(): + return + self._scheduleServiceStartup() + + def _cancelStartupTimer(self): + if self.startupTimer: + self.startupTimer.cancel() + self.startupTimer = None + + def _scheduleServiceStartup(self): + self._cancelStartupTimer() + self.startupTimer = threading.Timer(10, self.updateServices) + self.startupTimer.start() + + def updateServices(self): + with self.lock: + logger.debug("re-scheduling services due to sdr changes") + self.stopServices() + if not self.source.isAvailable(): + logger.debug("sdr source is unavailable") + return + cf = self.source.getProps()["center_freq"] + sr = self.source.getProps()["samp_rate"] + srh = sr / 2 + frequency_range = (cf - srh, cf + srh) + + dials = [ + dial + for dial in Bandplan.getSharedInstance().collectDialFrequencies(frequency_range) + if self.isSupported(dial["mode"]) + ] + + if not dials: + logger.debug("no services available") + return + + groups = self.optimizeResampling(dials, sr) + if groups is None: + for dial in dials: + self.services.append(self.setupService(dial["mode"], dial["frequency"], self.source)) + else: + for group in groups: + if len(group) > 1: + cf = self.get_center_frequency(group) + bw = self.get_bandwidth(group) + logger.debug("group center frequency: {0}, bandwidth: {1}".format(cf, bw)) + resampler_props = PropertyLayer() + resampler_props["center_freq"] = cf + resampler_props["samp_rate"] = bw + resampler = Resampler(resampler_props, self.source) + resampler.start() + + for dial in group: + self.services.append(self.setupService(dial["mode"], dial["frequency"], resampler)) + + # resampler goes in after the services since it must not be shutdown as long as the services are + # still running + self.services.append(resampler) + else: + dial = group[0] + self.services.append(self.setupService(dial["mode"], dial["frequency"], self.source)) + + def get_min_max(self, group): + frequencies = sorted(group, key=lambda f: f["frequency"]) + lowest = frequencies[0] + min = lowest["frequency"] + Modes.findByModulation(lowest["mode"]).get_bandpass().low_cut + highest = frequencies[-1] + max = highest["frequency"] + Modes.findByModulation(highest["mode"]).get_bandpass().high_cut + return min, max + + def get_center_frequency(self, group): + min, max = self.get_min_max(group) + return (min + max) / 2 + + def get_bandwidth(self, group): + minFreq, maxFreq = self.get_min_max(group) + # minimum bandwidth for a resampler: 25kHz + return max((maxFreq - minFreq) * 1.15, 25000) + + def optimizeResampling(self, freqs, bandwidth): + freqs = sorted(freqs, key=lambda f: f["frequency"]) + distances = [ + { + "frequency": freqs[i]["frequency"], + "distance": freqs[i + 1]["frequency"] - freqs[i]["frequency"], + } + for i in range(0, len(freqs) - 1) + ] + + distances = [d for d in distances if d["distance"] > 0] + + distances = sorted(distances, key=lambda f: f["distance"], reverse=True) + + def calculate_usage(num_splits): + splits = sorted([f["frequency"] for f in distances[0:num_splits]]) + previous = 0 + groups = [] + for split in splits: + groups.append([f for f in freqs if previous < f["frequency"] <= split]) + previous = split + groups.append([f for f in freqs if previous < f["frequency"]]) + + def get_total_bandwidth(group): + if len(group) > 1: + return bandwidth + len(group) * self.get_bandwidth(group) + else: + return bandwidth + + total_bandwidth = sum([get_total_bandwidth(group) for group in groups]) + return { + "num_splits": num_splits, + "total_bandwidth": total_bandwidth, + "groups": groups, + } + + usages = [calculate_usage(i) for i in range(0, len(freqs))] + # another possible outcome might be that it's best not to resample at all. this is a special case. + usages += [ + { + "num_splits": None, + "total_bandwidth": bandwidth * len(freqs), + "groups": [freqs], + } + ] + results = sorted(usages, key=lambda f: f["total_bandwidth"]) + + for r in results: + logger.debug("splits: {0}, total: {1}".format(r["num_splits"], r["total_bandwidth"])) + + best = results[0] + if best["num_splits"] is None: + return None + return best["groups"] + + def setupService(self, mode, frequency, source): + logger.debug("setting up service {0} on frequency {1}".format(mode, frequency)) + # TODO selecting outputs will need some more intelligence here + if mode == "packet": + output = AprsServiceOutput(frequency) + elif mode == "js8": + output = Js8ServiceOutput(frequency) + else: + output = WsjtServiceOutput(frequency) + d = Dsp(output) + d.nc_port = source.getPort() + center_freq = source.getProps()["center_freq"] + d.set_offset_freq(frequency - center_freq) + d.set_center_freq(center_freq) + modeObject = Modes.findByModulation(mode) + d.set_demodulator(modeObject.get_modulation()) + d.set_bandpass(modeObject.get_bandpass()) + d.set_secondary_demodulator(mode) + d.set_audio_compression("none") + d.set_samp_rate(source.getProps()["samp_rate"]) + d.set_temporary_directory(CoreConfig().get_temporary_directory()) + d.set_service() + d.start() + return d + + +class WsjtHandler(object): + def write_wsjt_message(self, msg): + pass + + +class AprsHandler(object): + def write_aprs_data(self, data): + pass + + +class Js8Handler(object): + def write_js8_message(self, frame: Js8Frame, freq: int): + pass + + +class Services(object): + handlers = {} + schedulers = {} + + @staticmethod + def start(): + config = Config.get() + config.wireProperty("services_enabled", Services._receiveEnabledEvent) + activeSources = SdrService.getActiveSources() + activeSources.wire(Services._receiveDeviceEvent) + for key, source in activeSources.items(): + Services.schedulers[key] = ServiceScheduler(source) + + @staticmethod + def _receiveEnabledEvent(state): + if state: + for key, source in SdrService.getActiveSources().__dict__().items(): + Services.handlers[key] = ServiceHandler(source) + else: + for handler in list(Services.handlers.values()): + handler.shutdown() + Services.handlers = {} + + @staticmethod + def _receiveDeviceEvent(changes): + for key, source in changes.items(): + if source is PropertyDeleted: + if key in Services.handlers: + Services.handlers[key].shutdown() + del Services.handlers[key] + if key in Services.schedulers: + Services.schedulers[key].shutdown() + del Services.schedulers[key] + else: + Services.schedulers[key] = ServiceScheduler(source) + if Config.get()["services_enabled"]: + Services.handlers[key] = ServiceHandler(source) + + @staticmethod + def stop(): + for handler in list(Services.handlers.values()): + handler.shutdown() + Services.handlers = {} + for scheduler in list(Services.schedulers.values()): + scheduler.shutdown() + Services.schedulers = {} diff --git a/openwebrx/owrx/service/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/service/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..9e9f7fc Binary files /dev/null and b/openwebrx/owrx/service/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/service/__pycache__/schedule.cpython-37.pyc b/openwebrx/owrx/service/__pycache__/schedule.cpython-37.pyc new file mode 100644 index 0000000..d835b2f Binary files /dev/null and b/openwebrx/owrx/service/__pycache__/schedule.cpython-37.pyc differ diff --git a/openwebrx/owrx/service/schedule.py b/openwebrx/owrx/service/schedule.py new file mode 100644 index 0000000..3e552f8 --- /dev/null +++ b/openwebrx/owrx/service/schedule.py @@ -0,0 +1,315 @@ +from datetime import datetime, timezone, timedelta +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass, SdrBusyState +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): + if "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) + # downwards compatibility + elif "schedule" in props: + return StaticSchedule(props["schedule"]) + + @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 stype in self.schedule and (previousEvent is not None or event["time"] - delta > now): + start = now if previousEvent is None else previousEvent + entries.append(DatetimeScheduleEntry(start, event["time"] - delta, self.schedule[stype])) + if useGreyline: + entries.append( + DatetimeScheduleEntry(event["time"] - delta, event["time"] + delta, self.schedule["greyline"]) + ) + previousEvent = event["time"] + delta + + logger.debug([str(e) for e in entries]) + return entries + + +class ServiceScheduler(SdrSourceEventClient): + def __init__(self, source): + self.source = source + self.selectionTimer = None + self.currentEntry = None + self.source.addClient(self) + self.schedule = None + props = self.source.getProps() + self.subscriptions = [] + self.subscriptions.append(props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange)) + self.subscriptions.append(props.wireProperty("scheduler", self.parseSchedule)) + # wireProperty calls parseSchedule with the initial value + # self.parseSchedule() + + def parseSchedule(self, *args): + props = self.source.getProps() + self.schedule = Schedule.parse(props) + self.scheduleSelection() + + def shutdown(self): + while self.subscriptions: + self.subscriptions.pop().cancel() + self.cancelTimer() + self.source.removeClient(self) + + def scheduleSelection(self, time=None): + if not self.source.isEnabled() or self.source.isFailed(): + return + seconds = 10 + if time is not None: + delta = time - datetime.utcnow() + seconds = delta.total_seconds() + self.cancelTimer() + self.selectionTimer = threading.Timer(seconds, self.selectProfile) + self.selectionTimer.start() + + def cancelTimer(self): + if self.selectionTimer: + self.selectionTimer.cancel() + + def getClientClass(self) -> SdrClientClass: + if self.currentEntry is None: + return SdrClientClass.INACTIVE + else: + return SdrClientClass.BACKGROUND + + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.STOPPING: + self.scheduleSelection() + + def onFail(self): + self.shutdown() + + def onShutdown(self): + self.shutdown() + + def onDisable(self): + self.cancelTimer() + + def onEnable(self): + self.scheduleSelection() + + def onBusyStateChange(self, state: SdrBusyState): + if state is SdrBusyState.IDLE: + self.scheduleSelection() + + def onFrequencyChange(self, changes): + self.scheduleSelection() + + def _setCurrentEntry(self, entry): + self.currentEntry = entry + + if entry is not None: + logger.debug("selected profile %s until %s", entry.getProfile(), entry.getScheduledEnd()) + self.scheduleSelection(entry.getScheduledEnd()) + + try: + self.source.activateProfile(entry.getProfile()) + self.source.start() + except KeyError: + pass + + # tell the source to re-evaluate its current status + # this should make it shut down if there's no other activity + # TODO this is an improvised solution, should probably be integrated / improved in SdrSourceEventClient + self.source.checkStatus() + + def selectProfile(self): + if self.source.hasClients(SdrClientClass.USER): + logger.debug("source has active users; not touching") + return + + if self.schedule is None: + self._setCurrentEntry(None) + logger.debug("no active schedule, scheduler standing by for external events.") + return + + logger.debug("source seems to be idle, selecting profile for background services") + self._setCurrentEntry(self.schedule.getCurrentEntry()) + + if self.currentEntry is None: + logger.debug("schedule did not return a current profile. checking next (future) entry...") + nextEntry = self.schedule.getNextEntry() + if nextEntry is not None: + self.scheduleSelection(nextEntry.getNextActivation()) + else: + logger.debug("no next entry available, scheduler standing by for external events.") + return diff --git a/openwebrx/owrx/soapy.py b/openwebrx/owrx/soapy.py new file mode 100644 index 0000000..25b5f35 --- /dev/null +++ b/openwebrx/owrx/soapy.py @@ -0,0 +1,21 @@ +class SoapySettings(object): + @staticmethod + def parse(dstr): + def decodeComponent(c): + kv = c.split("=", 1) + if len(kv) < 2: + return c + else: + return {kv[0]: kv[1]} + + return [decodeComponent(c) for c in dstr.split(",")] + + @staticmethod + def encode(dobj): + def encodeComponent(c): + if isinstance(c, str): + return c + else: + return ",".join(["{0}={1}".format(key, value) for key, value in c.items()]) + + return ",".join([encodeComponent(c) for c in dobj]) diff --git a/openwebrx/owrx/socket.py b/openwebrx/owrx/socket.py new file mode 100644 index 0000000..069a538 --- /dev/null +++ b/openwebrx/owrx/socket.py @@ -0,0 +1,10 @@ +import socket + + +def getAvailablePort(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("", 0)) + s.listen(1) + port = s.getsockname()[1] + s.close() + return port diff --git a/openwebrx/owrx/source/__init__.py b/openwebrx/owrx/source/__init__.py new file mode 100644 index 0000000..7f5e0a4 --- /dev/null +++ b/openwebrx/owrx/source/__init__.py @@ -0,0 +1,566 @@ +from owrx.config import Config +import threading +import subprocess +import os +import socket +import shlex +import time +import signal +import pkgutil +from abc import ABC, abstractmethod +from owrx.command import CommandMapper +from owrx.socket import getAvailablePort +from owrx.property import PropertyStack, PropertyLayer, PropertyFilter, PropertyCarousel, PropertyDeleted +from owrx.property.filter import ByLambda +from owrx.form.input import Input, TextInput, NumberInput, CheckboxInput, ModesInput, ExponentialInput +from owrx.form.input.converter import OptionalConverter +from owrx.form.input.device import GainInput, SchedulerInput, WaterfallLevelsInput +from owrx.form.input.validator import RequiredValidator +from owrx.form.section import OptionalSection +from owrx.feature import FeatureDetector +from typing import List +from enum import Enum + +import logging + +logger = logging.getLogger(__name__) + + +class SdrSourceState(Enum): + STOPPED = "Stopped" + STARTING = "Starting" + RUNNING = "Running" + STOPPING = "Stopping" + TUNING = "Tuning" + + def __str__(self): + return self.value + + +class SdrBusyState(Enum): + IDLE = 1 + BUSY = 2 + + +class SdrClientClass(Enum): + INACTIVE = 1 + BACKGROUND = 2 + USER = 3 + + +class SdrSourceEventClient(object): + def onStateChange(self, state: SdrSourceState): + pass + + def onBusyStateChange(self, state: SdrBusyState): + pass + + def onFail(self): + pass + + def onShutdown(self): + pass + + def onDisable(self): + pass + + def onEnable(self): + pass + + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.INACTIVE + + +class SdrProfileCarousel(PropertyCarousel): + def __init__(self, props): + super().__init__() + if "profiles" not in props: + return + + for profile_id, profile in props["profiles"].items(): + self.addLayer(profile_id, profile) + # activate first available profile + self.switch() + + props["profiles"].wire(self.handleProfileUpdate) + + def addLayer(self, profile_id, profile): + profile_stack = PropertyStack() + profile_stack.addLayer(0, PropertyLayer(profile_id=profile_id).readonly()) + profile_stack.addLayer(1, profile) + super().addLayer(profile_id, profile_stack) + + def handleProfileUpdate(self, changes): + for profile_id, profile in changes.items(): + if profile is PropertyDeleted: + self.removeLayer(profile_id) + else: + self.addLayer(profile_id, profile) + + def _getDefaultLayer(self): + # return the first available profile, or the default empty layer if we don't have any + if self.layers: + return next(iter(self.layers.values())) + return super()._getDefaultLayer() + + +class SdrSource(ABC): + def __init__(self, id, props): + self.id = id + + self.commandMapper = None + + self.props = PropertyStack() + + # layer 0 reserved for profile properties + self.profileCarousel = SdrProfileCarousel(props) + # prevent profile names from overriding the device name + self.props.addLayer(0, PropertyFilter(self.profileCarousel, ByLambda(lambda x: x != "name"))) + + # props from our device config + self.props.addLayer(1, props) + + # the sdr_id is constant, so we put it in a separate layer + # this is used to detect device changes, that are then sent to the client + self.props.addLayer(2, PropertyLayer(sdr_id=id).readonly()) + + # finally, accept global config properties from the top-level config + self.props.addLayer(3, Config.get()) + + self.sdrProps = self.props.filter(*self.getEventNames()) + + self.wireEvents() + + self.port = getAvailablePort() + self.monitor = None + self.clients = [] + self.spectrumClients = [] + self.spectrumThread = None + self.spectrumLock = threading.Lock() + self.process = None + self.modificationLock = threading.Lock() + self.state = SdrSourceState.STOPPED + self.enabled = "enabled" not in props or props["enabled"] + props.filter("enabled").wire(self._handleEnableChanged) + self.failed = False + self.busyState = SdrBusyState.IDLE + + self.validateProfiles() + + if self.isAlwaysOn() and self.isEnabled(): + self.start() + + def isEnabled(self): + return self.enabled + + def _handleEnableChanged(self, changes): + if "enabled" in changes and changes["enabled"] is not PropertyDeleted: + self.enabled = changes["enabled"] + else: + self.enabled = True + if not self.enabled: + self.stop() + for c in self.clients.copy(): + if self.isEnabled(): + c.onEnable() + else: + c.onDisable() + + def isFailed(self): + return self.failed + + def fail(self): + self.failed = True + for c in self.clients.copy(): + c.onFail() + + def validateProfiles(self): + props = PropertyStack() + props.addLayer(1, self.props) + for id, p in self.props["profiles"].items(): + props.replaceLayer(0, p) + if "center_freq" not in props: + logger.warning('Profile "%s" does not specify a center_freq', id) + continue + if "samp_rate" not in props: + logger.warning('Profile "%s" does not specify a samp_rate', id) + continue + if "start_freq" in props: + start_freq = props["start_freq"] + srh = props["samp_rate"] / 2 + center_freq = props["center_freq"] + if start_freq < center_freq - srh or start_freq > center_freq + srh: + logger.warning('start_freq for profile "%s" is out of range', id) + + def isAlwaysOn(self): + return "always-on" in self.props and self.props["always-on"] + + def getEventNames(self): + return [ + "samp_rate", + "center_freq", + "ppm", + "rf_gain", + "lfo_offset", + ] + list(self.getCommandMapper().keys()) + + def getCommandMapper(self): + if self.commandMapper is None: + self.commandMapper = CommandMapper() + return self.commandMapper + + @abstractmethod + def onPropertyChange(self, changes): + pass + + def wireEvents(self): + self.sdrProps.wire(self.onPropertyChange) + + def getCommand(self): + return [self.getCommandMapper().map(self.getCommandValues())] + + def activateProfile(self, profile_id): + logger.debug("activating profile {0} for {1}".format(profile_id, self.getId())) + try: + self.profileCarousel.switch(profile_id) + except KeyError: + logger.warning("invalid profile %s for sdr %s. ignoring", profile_id, self.getId()) + + def getId(self): + return self.id + + def getProfileId(self): + return self.props["profile_id"] + + def getProfiles(self): + return self.props["profiles"] + + def getName(self): + return self.props["name"] + + def getProps(self): + return self.props + + def getPort(self): + return self.port + + def getCommandValues(self): + dict = self.sdrProps.__dict__() + if "lfo_offset" in dict and dict["lfo_offset"] is not None: + dict["tuner_freq"] = dict["center_freq"] + dict["lfo_offset"] + else: + dict["tuner_freq"] = dict["center_freq"] + return dict + + def start(self): + with self.modificationLock: + if self.monitor: + return + + if self.isFailed(): + return + + try: + self.preStart() + except Exception: + logger.exception("Exception during preStart()") + + cmd = self.getCommand() + cmd = [c for c in cmd if c is not None] + + # don't use shell mode for commands without piping + if len(cmd) > 1: + # multiple commands with pipes + cmd = "|".join(cmd) + self.process = subprocess.Popen(cmd, shell=True, start_new_session=True) + else: + # single command + cmd = cmd[0] + # start_new_session can go as soon as there's no piped commands left + # the os.killpg call must be replaced with something more reasonable at the same time + self.process = subprocess.Popen(shlex.split(cmd), start_new_session=True) + logger.info("Started sdr source: " + cmd) + + available = False + failed = False + + def wait_for_process_to_end(): + nonlocal failed + rc = self.process.wait() + logger.debug("shut down with RC={0}".format(rc)) + self.process = None + self.monitor = None + if self.getState() is SdrSourceState.RUNNING: + self.fail() + else: + failed = True + self.setState(SdrSourceState.STOPPED) + + self.monitor = threading.Thread(target=wait_for_process_to_end, name="source_monitor") + self.monitor.start() + + retries = 1000 + while retries > 0 and not failed: + retries -= 1 + if self.monitor is None: + break + testsock = socket.socket() + try: + testsock.connect(("127.0.0.1", self.getPort())) + testsock.close() + available = True + break + except: + time.sleep(0.1) + + if not available: + failed = True + + try: + self.postStart() + except Exception: + logger.exception("Exception during postStart()") + failed = True + + if failed: + self.fail() + else: + self.setState(SdrSourceState.RUNNING) + + def preStart(self): + """ + override this method in subclasses if there's anything to be done before starting up the actual SDR + """ + pass + + def postStart(self): + """ + override this method in subclasses if there's things to do after the actual SDR has started up + """ + pass + + def isAvailable(self): + return self.monitor is not None + + def stop(self): + with self.modificationLock: + if self.process is not None: + self.setState(SdrSourceState.STOPPING) + try: + os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + except ProcessLookupError: + # been killed by something else, ignore + pass + if self.monitor: + self.monitor.join() + + def shutdown(self): + self.stop() + for c in self.clients.copy(): + c.onShutdown() + + def getClients(self, *args): + if not args: + return self.clients + return [c for c in self.clients if c.getClientClass() in args] + + def hasClients(self, *args): + return len(self.getClients(*args)) > 0 + + def addClient(self, c: SdrSourceEventClient): + if c in self.clients: + return + self.clients.append(c) + c.onStateChange(self.getState()) + hasUsers = self.hasClients(SdrClientClass.USER) + hasBackgroundTasks = self.hasClients(SdrClientClass.BACKGROUND) + if hasUsers or hasBackgroundTasks: + self.start() + self.setBusyState(SdrBusyState.BUSY if hasUsers else SdrBusyState.IDLE) + + def removeClient(self, c: SdrSourceEventClient): + if c not in self.clients: + return + + self.clients.remove(c) + + self.checkStatus() + + def checkStatus(self): + hasUsers = self.hasClients(SdrClientClass.USER) + self.setBusyState(SdrBusyState.BUSY if hasUsers else SdrBusyState.IDLE) + + # no need to check for users if we are always-on + if self.isAlwaysOn(): + return + + hasBackgroundTasks = self.hasClients(SdrClientClass.BACKGROUND) + if not hasUsers and not hasBackgroundTasks: + self.stop() + + def addSpectrumClient(self, c): + if c in self.spectrumClients: + return + + # local import due to circular depencency + from owrx.fft import SpectrumThread + + self.spectrumClients.append(c) + with self.spectrumLock: + if self.spectrumThread is None: + self.spectrumThread = SpectrumThread(self) + self.spectrumThread.start() + + def removeSpectrumClient(self, c): + try: + self.spectrumClients.remove(c) + except ValueError: + pass + with self.spectrumLock: + if not self.spectrumClients and self.spectrumThread is not None: + self.spectrumThread.stop() + self.spectrumThread = None + + def writeSpectrumData(self, data): + for c in self.spectrumClients: + c.write_spectrum_data(data) + + def getState(self) -> SdrSourceState: + return self.state + + def setState(self, state: SdrSourceState): + if state == self.state: + return + self.state = state + for c in self.clients.copy(): + c.onStateChange(state) + + def setBusyState(self, state: SdrBusyState): + if state == self.busyState: + return + self.busyState = state + for c in self.clients.copy(): + c.onBusyStateChange(state) + + +class SdrDeviceDescriptionMissing(Exception): + pass + + +class SdrDeviceDescription(object): + @staticmethod + def getByType(sdr_type: str) -> "SdrDeviceDescription": + try: + className = "".join(x for x in sdr_type.title() if x.isalnum()) + "DeviceDescription" + module = __import__("owrx.source.{0}".format(sdr_type), fromlist=[className]) + cls = getattr(module, className) + return cls() + except (ModuleNotFoundError, AttributeError): + raise SdrDeviceDescriptionMissing("Device description for type {} not available".format(sdr_type)) + + @staticmethod + def getTypes(): + def get_description(module_name): + try: + description = SdrDeviceDescription.getByType(module_name) + return description.getName() + except SdrDeviceDescriptionMissing: + return None + + descriptions = { + module_name: get_description(module_name) for _, module_name, _ in pkgutil.walk_packages(__path__) + } + # filter out empty names and unavailable types + fd = FeatureDetector() + return {k: v for k, v in descriptions.items() if v is not None and fd.is_available(k)} + + def getName(self): + """ + must be overridden with a textual representation of the device, to be used for device type selection + + :return: str + """ + return None + + def getDeviceInputs(self) -> List[Input]: + keys = self.getDeviceMandatoryKeys() + self.getDeviceOptionalKeys() + return [TextInput("name", "Device name", validator=RequiredValidator())] + [ + i for i in self.getInputs() if i.id in keys + ] + + def getProfileInputs(self) -> List[Input]: + keys = self.getProfileMandatoryKeys() + self.getProfileOptionalKeys() + return [TextInput("name", "Profile name", validator=RequiredValidator())] + [ + i for i in self.getInputs() if i.id in keys + ] + + def getInputs(self) -> List[Input]: + return [ + CheckboxInput("enabled", "Enable this device", converter=OptionalConverter(defaultFormValue=True)), + GainInput("rf_gain", "Device gain", self.hasAgc()), + NumberInput( + "ppm", + "Frequency correction", + append="ppm", + ), + CheckboxInput( + "always-on", + "Keep device running at all times", + infotext="Prevents shutdown of the device when idle. Useful for devices with unreliable startup.", + ), + CheckboxInput( + "services", + "Run background services on this device", + ), + ExponentialInput( + "lfo_offset", + "Oscillator offset", + "Hz", + infotext="Use this when the actual receiving frequency differs from the frequency to be tuned on the" + + " device.
    Formula: Center frequency + oscillator offset = sdr tune frequency", + ), + WaterfallLevelsInput("waterfall_levels", "Waterfall levels"), + SchedulerInput("scheduler", "Scheduler"), + ExponentialInput("center_freq", "Center frequency", "Hz"), + ExponentialInput("samp_rate", "Sample rate", "S/s"), + ExponentialInput("start_freq", "Initial frequency", "Hz"), + ModesInput("start_mod", "Initial modulation"), + NumberInput("initial_squelch_level", "Initial squelch level", append="dBFS"), + ] + + def hasAgc(self): + # default is True since most devices have agc. override in subclasses if agc is not available + return True + + def getDeviceMandatoryKeys(self): + return ["name", "enabled"] + + def getDeviceOptionalKeys(self): + return [ + "ppm", + "always-on", + "services", + "rf_gain", + "lfo_offset", + "waterfall_levels", + "scheduler", + ] + + def getProfileMandatoryKeys(self): + return ["name", "center_freq", "samp_rate", "start_freq", "start_mod"] + + def getProfileOptionalKeys(self): + return ["initial_squelch_level", "rf_gain", "lfo_offset", "waterfall_levels"] + + def getDeviceSection(self): + return OptionalSection( + "Device settings", self.getDeviceInputs(), self.getDeviceMandatoryKeys(), self.getDeviceOptionalKeys() + ) + + def getProfileSection(self): + return OptionalSection( + "Profile settings", + self.getProfileInputs(), + self.getProfileMandatoryKeys(), + self.getProfileOptionalKeys(), + ) diff --git a/openwebrx/owrx/source/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..54676db Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/__init__.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/airspy.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/airspy.cpython-37.pyc new file mode 100644 index 0000000..d707324 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/airspy.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/airspyhf.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/airspyhf.cpython-37.pyc new file mode 100644 index 0000000..ba4fef4 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/airspyhf.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/connector.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/connector.cpython-37.pyc new file mode 100644 index 0000000..0d4e6f9 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/connector.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/direct.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/direct.cpython-37.pyc new file mode 100644 index 0000000..1b1edab Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/direct.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/fcdpp.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/fcdpp.cpython-37.pyc new file mode 100644 index 0000000..4466db1 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/fcdpp.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/fifi_sdr.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/fifi_sdr.cpython-37.pyc new file mode 100644 index 0000000..eeea0da Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/fifi_sdr.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/hackrf.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/hackrf.cpython-37.pyc new file mode 100644 index 0000000..058146b Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/hackrf.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/hpsdr.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/hpsdr.cpython-37.pyc new file mode 100644 index 0000000..a89cbc9 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/hpsdr.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/lime_sdr.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/lime_sdr.cpython-37.pyc new file mode 100644 index 0000000..a2b6c79 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/lime_sdr.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/perseussdr.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/perseussdr.cpython-37.pyc new file mode 100644 index 0000000..d6b57eb Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/perseussdr.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/pluto_sdr.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/pluto_sdr.cpython-37.pyc new file mode 100644 index 0000000..eefa6ac Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/pluto_sdr.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/radioberry.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/radioberry.cpython-37.pyc new file mode 100644 index 0000000..ce192a4 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/radioberry.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/resampler.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/resampler.cpython-37.pyc new file mode 100644 index 0000000..815bfab Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/resampler.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/rtl_sdr.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/rtl_sdr.cpython-37.pyc new file mode 100644 index 0000000..a61dfc8 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/rtl_sdr.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/rtl_sdr_soapy.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/rtl_sdr_soapy.cpython-37.pyc new file mode 100644 index 0000000..04d4b1f Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/rtl_sdr_soapy.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/rtl_tcp.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/rtl_tcp.cpython-37.pyc new file mode 100644 index 0000000..d940ea0 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/rtl_tcp.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/runds.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/runds.cpython-37.pyc new file mode 100644 index 0000000..6379026 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/runds.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/sddc.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/sddc.cpython-37.pyc new file mode 100644 index 0000000..03d3318 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/sddc.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/sdrplay.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/sdrplay.cpython-37.pyc new file mode 100644 index 0000000..376e514 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/sdrplay.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/soapy.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/soapy.cpython-37.pyc new file mode 100644 index 0000000..87f455f Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/soapy.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/soapy_remote.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/soapy_remote.cpython-37.pyc new file mode 100644 index 0000000..5847475 Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/soapy_remote.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/__pycache__/uhd.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/uhd.cpython-37.pyc new file mode 100644 index 0000000..04ae29b Binary files /dev/null and b/openwebrx/owrx/source/__pycache__/uhd.cpython-37.pyc differ diff --git a/openwebrx/owrx/source/airspy.py b/openwebrx/owrx/source/airspy.py new file mode 100644 index 0000000..53e7f13 --- /dev/null +++ b/openwebrx/owrx/source/airspy.py @@ -0,0 +1,44 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form.input import Input, CheckboxInput +from owrx.form.input.device import BiasTeeInput +from typing import List + + +class AirspySource(SoapyConnectorSource): + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update( + { + "bias_tee": "biastee", + "bitpack": "bitpack", + } + ) + return mappings + + def getDriver(self): + return "airspy" + + +class AirspyDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "Airspy R2 or Mini" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + BiasTeeInput(), + CheckboxInput( + "bitpack", + "Enable bit-packing", + infotext="Packs two 12-bit samples into 3 bytes." + + " Lowers USB bandwidth consumption, increases CPU load", + ), + ] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee", "bitpack"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["bias_tee"] + + def getGainStages(self): + return ["LNA", "MIX", "VGA"] diff --git a/openwebrx/owrx/source/airspyhf.py b/openwebrx/owrx/source/airspyhf.py new file mode 100644 index 0000000..82cc03b --- /dev/null +++ b/openwebrx/owrx/source/airspyhf.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class AirspyhfSource(SoapyConnectorSource): + def getDriver(self): + return "airspyhf" + + +class AirspyhfDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "Airspy HF+ or Discovery" diff --git a/openwebrx/owrx/source/connector.py b/openwebrx/owrx/source/connector.py new file mode 100644 index 0000000..58c535d --- /dev/null +++ b/openwebrx/owrx/source/connector.py @@ -0,0 +1,100 @@ +from owrx.source import SdrSource, SdrDeviceDescription +from owrx.socket import getAvailablePort +from owrx.property import PropertyDeleted +import socket +from owrx.command import Flag, Option +from typing import List +from owrx.form.input import Input, NumberInput, CheckboxInput + +import logging + +logger = logging.getLogger(__name__) + + +class ConnectorSource(SdrSource): + def __init__(self, id, props): + self.controlSocket = None + self.controlPort = getAvailablePort() + super().__init__(id, props) + + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setMappings( + { + "samp_rate": Option("-s"), + "tuner_freq": Option("-f"), + "port": Option("-p"), + "controlPort": Option("-c"), + "device": Option("-d"), + "iqswap": Flag("-i"), + "rtltcp_compat": Option("-r"), + "ppm": Option("-P"), + "rf_gain": Option("-g"), + } + ) + ) + + def sendControlMessage(self, changes): + for prop, value in changes.items(): + if value is PropertyDeleted: + value = None + logger.debug("sending property change over control socket: {0} changed to {1}".format(prop, value)) + self.controlSocket.sendall("{prop}:{value}\n".format(prop=prop, value=value).encode()) + + def onPropertyChange(self, changes): + if self.monitor is None: + return + if ( + ("center_freq" in changes or "lfo_offset" in changes) + and "lfo_offset" in self.sdrProps + and self.sdrProps["lfo_offset"] is not None + ): + changes["center_freq"] = self.sdrProps["center_freq"] + self.sdrProps["lfo_offset"] + changes.pop("lfo_offset", None) + self.sendControlMessage(changes) + + def postStart(self): + logger.debug("opening control socket...") + self.controlSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.controlSocket.connect(("localhost", self.controlPort)) + + def stop(self): + super().stop() + if self.controlSocket: + self.controlSocket.close() + self.controlSocket = None + + def getControlPort(self): + return self.controlPort + + def getCommandValues(self): + values = super().getCommandValues() + values["port"] = self.getPort() + values["controlPort"] = self.getControlPort() + return values + + +class ConnectorDeviceDescription(SdrDeviceDescription): + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + NumberInput( + "rtltcp_compat", + "Port for rtl_tcp compatible data", + infotext="Activate an rtl_tcp compatible interface on the port number specified.
    " + + "Note: Port is only available on the local machine, not on the network.
    " + + "Note: IQ data may be degraded by the downsampling process to 8 bits.", + ), + CheckboxInput( + "iqswap", + "Swap I and Q channels", + infotext="Swapping inverts the spectrum, so this is useful in combination with an inverting mixer", + ), + ] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["rtltcp_compat", "iqswap"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["iqswap"] diff --git a/openwebrx/owrx/source/direct.py b/openwebrx/owrx/source/direct.py new file mode 100644 index 0000000..d11d83b --- /dev/null +++ b/openwebrx/owrx/source/direct.py @@ -0,0 +1,53 @@ +from abc import ABCMeta +from owrx.source import SdrSource, SdrDeviceDescription + +import logging + +logger = logging.getLogger(__name__) + + +class DirectSource(SdrSource, metaclass=ABCMeta): + def onPropertyChange(self, changes): + logger.debug("restarting sdr source due to property changes: {0}".format(changes)) + self.stop() + self.sleepOnRestart() + self.start() + + def nmux_memory(self): + # in megabytes. This sets the approximate size of the circular buffer used by nmux. + return 50 + + def getNmuxCommand(self): + props = self.sdrProps + + nmux_bufcnt = nmux_bufsize = 0 + while nmux_bufsize < props["samp_rate"] / 4: + nmux_bufsize += 4096 + while nmux_bufsize * nmux_bufcnt < self.nmux_memory() * 1e6: + nmux_bufcnt += 1 + if nmux_bufcnt == 0 or nmux_bufsize == 0: + raise ValueError("Error: unable to calculate nmux buffer parameters.") + + return [ + "nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" + % ( + nmux_bufsize, + nmux_bufcnt, + self.port, + ) + ] + + def getCommand(self): + return super().getCommand() + self.getFormatConversion() + self.getNmuxCommand() + + # override this in subclasses, if necessary + def getFormatConversion(self): + return [] + + # override this in subclasses, if necessary + def sleepOnRestart(self): + pass + + +class DirectSourceDeviceDescription(SdrDeviceDescription): + pass diff --git a/openwebrx/owrx/source/fcdpp.py b/openwebrx/owrx/source/fcdpp.py new file mode 100644 index 0000000..8f9b7af --- /dev/null +++ b/openwebrx/owrx/source/fcdpp.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class FcdppSource(SoapyConnectorSource): + def getDriver(self): + return "fcdpp" + + +class FcdppDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "FunCube Dongle Pro+" diff --git a/openwebrx/owrx/source/fifi_sdr.py b/openwebrx/owrx/source/fifi_sdr.py new file mode 100644 index 0000000..cf6e3e7 --- /dev/null +++ b/openwebrx/owrx/source/fifi_sdr.py @@ -0,0 +1,44 @@ +from owrx.command import Option +from owrx.source.direct import DirectSource, DirectSourceDeviceDescription +from subprocess import Popen + +import logging + +logger = logging.getLogger(__name__) + + +class FifiSdrSource(DirectSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("arecord") + .setMappings({"device": Option("-D"), "samp_rate": Option("-r")}) + .setStatic("-t raw -f S16_LE -c2 -") + ) + + def getEventNames(self): + return super().getEventNames() + ["device"] + + def getFormatConversion(self): + return ["csdr convert_s16_f", "csdr gain_ff 5"] + + def sendRockProgFrequency(self, frequency): + process = Popen(["rockprog", "--vco", "-w", "--freq={}".format(frequency / 1e6)]) + process.communicate() + rc = process.wait() + if rc != 0: + logger.warning("rockprog failed to set frequency; rc=%i", rc) + + def preStart(self): + values = self.getCommandValues() + self.sendRockProgFrequency(values["tuner_freq"]) + + def onPropertyChange(self, changes): + if "center_freq" in changes: + self.sendRockProgFrequency(changes["center_freq"]) + + +class FifiSdrDeviceDescription(DirectSourceDeviceDescription): + def getName(self): + return "FiFi SDR" diff --git a/openwebrx/owrx/source/hackrf.py b/openwebrx/owrx/source/hackrf.py new file mode 100644 index 0000000..bd16a3d --- /dev/null +++ b/openwebrx/owrx/source/hackrf.py @@ -0,0 +1,31 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form.input import Input +from owrx.form.input.device import BiasTeeInput +from typing import List + + +class HackrfSource(SoapyConnectorSource): + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update({"bias_tee": "bias_tx"}) + return mappings + + def getDriver(self): + return "hackrf" + + +class HackrfDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "HackRF" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [BiasTeeInput()] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["bias_tee"] + + def getGainStages(self): + return ["LNA", "AMP", "VGA"] diff --git a/openwebrx/owrx/source/hpsdr.py b/openwebrx/owrx/source/hpsdr.py new file mode 100644 index 0000000..71cb691 --- /dev/null +++ b/openwebrx/owrx/source/hpsdr.py @@ -0,0 +1,62 @@ +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from owrx.command import Option +from owrx.form.error import ValidationError +from owrx.form.input import Input, NumberInput, TextInput +from owrx.form.input.validator import RangeValidator +from typing import List + +# In order to use an HPSDR radio, you must install hpsdrconnector from https://github.com/jancona/hpsdrconnector +# These are the command line options available: +# --frequency uint +# Tune to specified frequency in Hz (default 7100000) +# --gain uint +# LNA gain between 0 (-12dB) and 60 (48dB) (default 20) +# --radio string +# IP address of radio (default use first radio discovered) +# --samplerate uint +# Use the specified samplerate: one of 48000, 96000, 192000, 384000 (default 96000) +# --debug +# Emit debug log messages on stdout +# +# If you omit `remote` from config_webrx.py, hpsdrconnector will use the HPSDR discovery protocol +# to find radios on your local network and will connect to the first radio it discovered. + + +class HpsdrSource(ConnectorSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("hpsdrconnector") + .setMappings( + { + "tuner_freq": Option("--frequency"), + "samp_rate": Option("--samplerate"), + "remote": Option("--radio"), + "rf_gain": Option("--gain"), + } + ) + ) + +class RemoteInput(TextInput): + def __init__(self): + super().__init__( + "remote", "Remote IP", infotext="Remote IP address to connect to." + ) + +class HpsdrDeviceDescription(ConnectorDeviceDescription): + def getName(self): + return "HPSDR devices (Hermes / Hermes Lite 2 / Red Pitaya)" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + RemoteInput(), + NumberInput("rf_gain", "LNA Gain", "LNA gain between 0 (-12dB) and 60 (48dB)", validator=RangeValidator(0, 60)), + ] + + def getDeviceOptionalKeys(self): + return list(filter(lambda x : x not in ["rtltcp_compat", "iqswap"], super().getDeviceOptionalKeys())) + ["remote"] + + def getProfileOptionalKeys(self): + return list(filter(lambda x : x != "iqswap", super().getProfileOptionalKeys())) + diff --git a/openwebrx/owrx/source/lime_sdr.py b/openwebrx/owrx/source/lime_sdr.py new file mode 100644 index 0000000..f7e6ba4 --- /dev/null +++ b/openwebrx/owrx/source/lime_sdr.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class LimeSdrSource(SoapyConnectorSource): + def getDriver(self): + return "lime" + + +class LimeSdrDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "LimeSDR device" diff --git a/openwebrx/owrx/source/perseussdr.py b/openwebrx/owrx/source/perseussdr.py new file mode 100644 index 0000000..ff5b0c5 --- /dev/null +++ b/openwebrx/owrx/source/perseussdr.py @@ -0,0 +1,79 @@ +from owrx.source.direct import DirectSource, DirectSourceDeviceDescription +from owrx.command import Option, Flag +from owrx.form.input import Input, DropdownEnum, DropdownInput, CheckboxInput +from typing import List + + +# +# In order to interface Perseus hardware, we resolve to use the +# perseustest utility that comes with libperseus-sdr support package. +# Below the base options used are shown: +# +# -p output I/Q samples as 32 bits floating point +# -d -1 suppress debug messages +# -a don't test attenuators on startup +# -t 0 runs indefinitely +# -o - output samples on stdout +# +# As we are already returning I/Q samples as pairs of 32 bits +# floating points (option -p),no need for further conversions, +# so the method getFormatConversion(self) is not implemented at all. + + +class PerseussdrSource(DirectSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("perseustest -p -d -1 -a -t 0 -o - ") + .setMappings( + { + "samp_rate": Option("-s"), + "tuner_freq": Option("-f"), + "attenuator": Option("-u"), + "adc_preamp": Flag("-m"), + "adc_dither": Flag("-x"), + "wideband": Flag("-w"), + } + ) + ) + + +class AttenuatorOptions(DropdownEnum): + ATTENUATOR_0 = 0 + ATTENUATOR_10 = -10 + ATTENUATOR_20 = -20 + ATTENUATOR_30 = -30 + + def __str__(self): + return "{value} dB".format(value=self.value) + + +class PerseussdrDeviceDescription(DirectSourceDeviceDescription): + def getName(self): + return "Perseus SDR" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + DropdownInput("attenuator", "Attenuator", options=AttenuatorOptions), + CheckboxInput("adc_preamp", "Activate ADC preamp"), + CheckboxInput("adc_dither", "Enable ADC dithering"), + CheckboxInput("wideband", "Disable analog filters"), + ] + + def getDeviceOptionalKeys(self): + # no rf_gain + return [key for key in super().getDeviceOptionalKeys() if key != "rf_gain"] + [ + "attenuator", + "adc_preamp", + "adc_dither", + "wideband", + ] + + def getProfileOptionalKeys(self): + return [key for key in super().getProfileOptionalKeys() if key != "rf_gain"] + [ + "attenuator", + "adc_preamp", + "adc_dither", + "wideband", + ] diff --git a/openwebrx/owrx/source/pluto_sdr.py b/openwebrx/owrx/source/pluto_sdr.py new file mode 100644 index 0000000..593b5c7 --- /dev/null +++ b/openwebrx/owrx/source/pluto_sdr.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class PlutoSdrSource(SoapyConnectorSource): + def getDriver(self): + return "plutosdr" + + +class PlutoSdrDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "PlutoSDR" diff --git a/openwebrx/owrx/source/radioberry.py b/openwebrx/owrx/source/radioberry.py new file mode 100644 index 0000000..932a5e4 --- /dev/null +++ b/openwebrx/owrx/source/radioberry.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class RadioberrySource(SoapyConnectorSource): + def getDriver(self): + return "radioberry" + + +class RadioberryDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "RadioBerry" diff --git a/openwebrx/owrx/source/resampler.py b/openwebrx/owrx/source/resampler.py new file mode 100644 index 0000000..03c2097 --- /dev/null +++ b/openwebrx/owrx/source/resampler.py @@ -0,0 +1,38 @@ +from .direct import DirectSource + +import logging + +logger = logging.getLogger(__name__) + + +class Resampler(DirectSource): + def onPropertyChange(self, changes): + logger.warning("Resampler is unable to handle property changes: {0}".format(changes)) + + def __init__(self, props, sdr): + sdrProps = sdr.getProps() + self.shift = (sdrProps["center_freq"] - props["center_freq"]) / sdrProps["samp_rate"] + self.decimation = int(float(sdrProps["samp_rate"]) / props["samp_rate"]) + if_samp_rate = sdrProps["samp_rate"] / self.decimation + self.transition_bw = 0.15 * (if_samp_rate / float(sdrProps["samp_rate"])) + props["samp_rate"] = if_samp_rate + + self.sdr = sdr + super().__init__(None, props) + + def getCommand(self): + return [ + "nc -v 127.0.0.1 {nc_port}".format(nc_port=self.sdr.getPort()), + "csdr shift_addfast_cc {shift}".format(shift=self.shift), + "csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING".format( + decimation=self.decimation, ddc_transition_bw=self.transition_bw + ), + ] + self.getNmuxCommand() + + def activateProfile(self, profile_id=None): + logger.warning("Resampler does not support setting profiles") + pass + + def validateProfiles(self): + # resampler does not support profiles + pass diff --git a/openwebrx/owrx/source/rtl_sdr.py b/openwebrx/owrx/source/rtl_sdr.py new file mode 100644 index 0000000..cb9e954 --- /dev/null +++ b/openwebrx/owrx/source/rtl_sdr.py @@ -0,0 +1,37 @@ +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from owrx.command import Flag, Option +from typing import List +from owrx.form.input import Input, TextInput +from owrx.form.input.device import BiasTeeInput, DirectSamplingInput + + +class RtlSdrSource(ConnectorSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("rtl_connector") + .setMappings({"bias_tee": Flag("-b"), "direct_sampling": Option("-e")}) + ) + + +class RtlSdrDeviceDescription(ConnectorDeviceDescription): + def getName(self): + return "RTL-SDR device" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + TextInput( + "device", + "Device identifier", + infotext="Device serial number or index", + ), + BiasTeeInput(), + DirectSamplingInput(), + ] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["device", "bias_tee", "direct_sampling"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["bias_tee", "direct_sampling"] diff --git a/openwebrx/owrx/source/rtl_sdr_soapy.py b/openwebrx/owrx/source/rtl_sdr_soapy.py new file mode 100644 index 0000000..a308c7d --- /dev/null +++ b/openwebrx/owrx/source/rtl_sdr_soapy.py @@ -0,0 +1,28 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form.input import Input +from owrx.form.input.device import BiasTeeInput, DirectSamplingInput +from typing import List + + +class RtlSdrSoapySource(SoapyConnectorSource): + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update({"direct_sampling": "direct_samp", "bias_tee": "biastee"}) + return mappings + + def getDriver(self): + return "rtlsdr" + + +class RtlSdrSoapyDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "RTL-SDR device (via SoapySDR)" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [BiasTeeInput(), DirectSamplingInput()] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee", "direct_sampling"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["bias_tee", "direct_sampling"] diff --git a/openwebrx/owrx/source/rtl_tcp.py b/openwebrx/owrx/source/rtl_tcp.py new file mode 100644 index 0000000..6c3f7d2 --- /dev/null +++ b/openwebrx/owrx/source/rtl_tcp.py @@ -0,0 +1,32 @@ +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from owrx.command import Flag, Option, Argument +from owrx.form.input import Input +from owrx.form.input.device import RemoteInput +from typing import List + + +class RtlTcpSource(ConnectorSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("rtl_tcp_connector") + .setMappings( + { + "bias_tee": Flag("-b"), + "direct_sampling": Option("-e"), + "remote": Argument(), + } + ) + ) + + +class RtlTcpDeviceDescription(ConnectorDeviceDescription): + def getName(self): + return "RTL-SDR device (via rtl_tcp)" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [RemoteInput()] + + def getDeviceMandatoryKeys(self): + return super().getDeviceMandatoryKeys() + ["remote"] diff --git a/openwebrx/owrx/source/runds.py b/openwebrx/owrx/source/runds.py new file mode 100644 index 0000000..9d4e9b9 --- /dev/null +++ b/openwebrx/owrx/source/runds.py @@ -0,0 +1,54 @@ +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from owrx.command import Argument, Flag, Option +from owrx.form.input import Input, DropdownInput, DropdownEnum, CheckboxInput +from owrx.form.input.device import RemoteInput +from typing import List + + +class RundsSource(ConnectorSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("runds_connector") + .setMappings( + { + "long": Flag("-l"), + "remote": Argument(), + "protocol": Option("-m"), + } + ) + ) + + +class ProtocolOptions(DropdownEnum): + PROTOCOL_EB200 = ("eb200", "EB200 protocol") + PROTOCOL_AMMOS = ("ammos", "Ammos protocol") + + def __new__(cls, *args, **kwargs): + value, description = args + obj = object.__new__(cls) + obj._value_ = value + obj.description = description + return obj + + def __str__(self): + return self.description + + +class RundsDeviceDescription(ConnectorDeviceDescription): + def getName(self): + return "R&S device using EB200 or Ammos protocol" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + RemoteInput(), + DropdownInput("protocol", "Protocol", ProtocolOptions), + CheckboxInput("long", "Use 32-bit sample size (LONG)"), + ] + + def getDeviceMandatoryKeys(self): + return super().getDeviceMandatoryKeys() + ["remote"] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["protocol", "long"] diff --git a/openwebrx/owrx/source/sddc.py b/openwebrx/owrx/source/sddc.py new file mode 100644 index 0000000..5c3255f --- /dev/null +++ b/openwebrx/owrx/source/sddc.py @@ -0,0 +1,14 @@ +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription + + +class SddcSource(ConnectorSource): + def getCommandMapper(self): + return super().getCommandMapper().setBase("sddc_connector") + + +class SddcDeviceDescription(ConnectorDeviceDescription): + def getName(self): + return "BBRF103 / RX666 / RX888 device (libsddc)" + + def hasAgc(self): + return False diff --git a/openwebrx/owrx/source/sdrplay.py b/openwebrx/owrx/source/sdrplay.py new file mode 100644 index 0000000..454e472 --- /dev/null +++ b/openwebrx/owrx/source/sdrplay.py @@ -0,0 +1,64 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form.input import Input, CheckboxInput, DropdownInput, DropdownEnum +from owrx.form.input.device import BiasTeeInput +from typing import List + + +class SdrplaySource(SoapyConnectorSource): + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update( + { + "bias_tee": "biasT_ctrl", + "rf_notch": "rfnotch_ctrl", + "dab_notch": "dabnotch_ctrl", + "if_mode": "if_mode", + "external_reference": "extref_ctrl", + } + ) + return mappings + + def getDriver(self): + return "sdrplay" + + +class IfModeOptions(DropdownEnum): + IFMODE_ZERO_IF = "Zero-IF" + IFMODE_450 = "450kHz" + IFMODE_1620 = "1620kHz" + IFMODE_2048 = "2048kHz" + + def __str__(self): + return self.value + + +class SdrplayDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "SDRPlay device (RSP1, RSP2, RSPDuo, RSPDx)" + + def getGainStages(self): + return ["RFGR", "IFGR"] + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + BiasTeeInput(), + CheckboxInput( + "rf_notch", + "Enable RF notch filter", + ), + CheckboxInput( + "dab_notch", + "Enable DAB notch filter", + ), + DropdownInput( + "if_mode", + "IF Mode", + IfModeOptions, + ), + ] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee", "rf_notch", "dab_notch", "if_mode"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["bias_tee", "rf_notch", "dab_notch", "if_mode"] diff --git a/openwebrx/owrx/source/soapy.py b/openwebrx/owrx/source/soapy.py new file mode 100644 index 0000000..745343c --- /dev/null +++ b/openwebrx/owrx/source/soapy.py @@ -0,0 +1,108 @@ +from abc import ABCMeta, abstractmethod +from owrx.command import Option +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from typing import List +from owrx.form.input import Input, TextInput +from owrx.form.input.device import GainInput +from owrx.soapy import SoapySettings + + +class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("soapy_connector") + .setMappings( + { + "antenna": Option("-a"), + "soapy_settings": Option("-t"), + } + ) + ) + + """ + must be implemented by child classes to be able to build a driver-based device selector by default. + return value must be the corresponding soapy driver identifier. + """ + + @abstractmethod + def getDriver(self): + pass + + def getEventNames(self): + return super().getEventNames() + list(self.getSoapySettingsMappings().keys()) + + def buildSoapyDeviceParameters(self, parsed, values): + """ + this method always attempts to inject a driver= part into the soapysdr query, depending on what connector was used. + this prevents the soapy_connector from using the wrong device in scenarios where there's no same-type sdrs. + """ + parsed = [v for v in parsed if "driver" not in v] + parsed += [{"driver": self.getDriver()}] + return parsed + + def getSoapySettingsMappings(self): + return {} + + def buildSoapySettings(self, values): + settings = {} + for k, v in self.getSoapySettingsMappings().items(): + if k in values and values[k] is not None: + settings[v] = self.convertSoapySettingsValue(values[k]) + return settings + + def convertSoapySettingsValue(self, value): + if isinstance(value, bool): + return "true" if value else "false" + return value + + def getCommandValues(self): + values = super().getCommandValues() + if "device" in values and values["device"] is not None: + parsed = SoapySettings.parse(values["device"]) + else: + parsed = [] + modified = self.buildSoapyDeviceParameters(parsed, values) + values["device"] = SoapySettings.encode(modified) + settings = ",".join(["{0}={1}".format(k, v) for k, v in self.buildSoapySettings(values).items()]) + if len(settings): + values["soapy_settings"] = settings + return values + + def onPropertyChange(self, changes): + mappings = self.getSoapySettingsMappings() + settings = {} + for prop, value in changes.items(): + if prop in mappings.keys(): + settings[mappings[prop]] = self.convertSoapySettingsValue(value) + if settings: + changes["settings"] = ",".join("{0}={1}".format(k, v) for k, v in settings.items()) + super().onPropertyChange(changes) + + +class SoapyConnectorDeviceDescription(ConnectorDeviceDescription): + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + TextInput( + "device", + "Device identifier", + infotext='SoapySDR device identifier string (example: "serial=123456789")', + ), + GainInput( + "rf_gain", + "Device Gain", + gain_stages=self.getGainStages(), + has_agc=self.hasAgc(), + ), + TextInput("antenna", "Antenna"), + ] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["device", "rf_gain", "antenna"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["antenna"] + + def getGainStages(self): + return None diff --git a/openwebrx/owrx/source/soapy_remote.py b/openwebrx/owrx/source/soapy_remote.py new file mode 100644 index 0000000..efbe5c6 --- /dev/null +++ b/openwebrx/owrx/source/soapy_remote.py @@ -0,0 +1,43 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form.input import Input, TextInput +from owrx.form.input.device import RemoteInput +from owrx.form.input.converter import OptionalConverter +from typing import List + + +class SoapyRemoteSource(SoapyConnectorSource): + def getEventNames(self): + return super().getEventNames() + ["remote", "remote_driver"] + + def getDriver(self): + return "remote" + + def buildSoapyDeviceParameters(self, parsed, values): + params = super().buildSoapyDeviceParameters(parsed, values) + params = [v for v in params if not "remote" in params] + params += [{"remote": values["remote"]}] + if "remote_driver" in values and values["remote_driver"] is not None: + params += [{"remote:driver": values["remote_driver"]}] + return params + + +class SoapyRemoteDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "Device connected to a SoapyRemote server" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + RemoteInput(), + TextInput( + "remote_driver", + "Remote driver", + infotext="SoapySDR driver to be used on the remote SoapySDRServer", + converter=OptionalConverter(), + ), + ] + + def getDeviceMandatoryKeys(self): + return super().getDeviceMandatoryKeys() + ["remote"] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["remote_driver"] diff --git a/openwebrx/owrx/source/uhd.py b/openwebrx/owrx/source/uhd.py new file mode 100644 index 0000000..56f66b6 --- /dev/null +++ b/openwebrx/owrx/source/uhd.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class UhdSource(SoapyConnectorSource): + def getDriver(self): + return "uhd" + + +class UhdDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "Ettus Research USRP device" diff --git a/openwebrx/owrx/users.py b/openwebrx/owrx/users.py new file mode 100644 index 0000000..be103b1 --- /dev/null +++ b/openwebrx/owrx/users.py @@ -0,0 +1,237 @@ +from abc import ABC, abstractmethod +from owrx.config.core import CoreConfig +from datetime import datetime, timezone +import json +import hashlib +import os +import stat + +import logging + +logger = logging.getLogger(__name__) + + +class PasswordException(Exception): + pass + + +class Password(ABC): + @staticmethod + def from_dict(d: dict): + if "encoding" not in d: + raise PasswordException("password encoding not set") + if d["encoding"] == "string": + return CleartextPassword(d) + elif d["encoding"] == "hash": + return HashedPassword(d) + raise PasswordException("invalid passord encoding: {0}".format(d["type"])) + + @abstractmethod + def is_valid(self, inp: str) -> bool: + pass + + @abstractmethod + def toJson(self) -> dict: + pass + + +class CleartextPassword(Password): + def __init__(self, pwinfo): + if isinstance(pwinfo, str): + self._value = pwinfo + elif isinstance(pwinfo, dict): + self._value = pwinfo["value"] + else: + raise ValueError("invalid argument to ClearTextPassword()") + + def is_valid(self, inp: str) -> bool: + return self._value == inp + + def toJson(self) -> dict: + return { + "encoding": "string", + "value": self._value + } + + +class HashedPassword(Password): + def __init__(self, pwinfo, algorithm="sha256"): + self.iterations = 100000 + if isinstance(pwinfo, str): + self._createFromString(pwinfo, algorithm) + else: + self._loadFromDict(pwinfo) + + def _createFromString(self, pw: str, algorithm: str): + self._algorithm = algorithm + self._salt = os.urandom(32) + dk = hashlib.pbkdf2_hmac(self._algorithm, pw.encode(), self._salt, self.iterations) + self._hash = dk.hex() + pass + + def _loadFromDict(self, d: dict): + self._hash = d["value"] + self._algorithm = d["algorithm"] + self._salt = bytes.fromhex(d["salt"]) + pass + + def is_valid(self, inp: str) -> bool: + dk = hashlib.pbkdf2_hmac(self._algorithm, inp.encode(), self._salt, self.iterations) + return dk.hex() == self._hash + + def toJson(self) -> dict: + return { + "encoding": "hash", + "value": self._hash, + "algorithm": self._algorithm, + "salt": self._salt.hex(), + } + + +DefaultPasswordClass = HashedPassword + + +class User(object): + def __init__(self, name: str, enabled: bool, password: Password, must_change_password: bool = False): + self.name = name + self.enabled = enabled + self.password = password + self.must_change_password = must_change_password + + def toJson(self): + return { + "user": self.name, + "enabled": self.enabled, + "must_change_password": self.must_change_password, + "password": self.password.toJson() + } + + @staticmethod + def fromJson(d): + if "user" in d and "password" in d and "enabled" in d: + mcp = d["must_change_password"] if "must_change_password" in d else False + return User(d["user"], d["enabled"], Password.from_dict(d["password"]), mcp) + + def setPassword(self, password: Password, must_change_password: bool = None): + self.password = password + if must_change_password is not None: + self.must_change_password = must_change_password + + def is_enabled(self): + return self.enabled + + def enable(self): + self.enabled = True + + def disable(self): + self.enabled = False + + +class UserList(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if UserList.sharedInstance is None: + UserList.sharedInstance = UserList() + return UserList.sharedInstance + + def __init__(self): + self.file_modified = None + self.users = {} + + def refresh(self): + if self.file_modified is None or self._getUsersFileModifiedTimestamp() > self.file_modified: + logger.debug("reloading users from disk due to file modification") + self.users = self._loadUsers() + + def _getUsersFile(self): + config = CoreConfig() + return "{data_directory}/users.json".format(data_directory=config.get_data_directory()) + + def _getUsersFileModifiedTimestamp(self): + timestamp = 0 + try: + timestamp = os.path.getmtime(self._getUsersFile()) + except FileNotFoundError: + pass + return datetime.fromtimestamp(timestamp, timezone.utc) + + def _loadUsers(self): + usersFile = self._getUsersFile() + # to avoid concurrency issues and problems when parsing errors occur: + # get early, store late + modified = self._getUsersFileModifiedTimestamp() + try: + with open(usersFile, "r") as f: + users_json = json.load(f) + + users = {u.name: u for u in [User.fromJson(d) for d in users_json]} + self.file_modified = modified + return users + except FileNotFoundError: + self.file_modified = modified + return {} + except json.JSONDecodeError: + logger.exception("error while parsing users file %s", usersFile) + return {} + except Exception: + logger.exception("error while processing users from %s", usersFile) + return {} + + def _userToJson(self, u): + return u.toJson() + + def store(self): + usersFile = self._getUsersFile() + users = [u.toJson() for u in self.values()] + try: + # don't write directly to file to avoid corruption on exceptions + jsonContent = json.dumps(users, indent=4) + with open(usersFile, "w") as f: + f.write(jsonContent) + # file should be readable by us only + os.chmod(usersFile, stat.S_IWUSR + stat.S_IRUSR) + except Exception: + logger.exception("error while writing users file %s", usersFile) + self.refresh() + + def _getUsername(self, user): + if isinstance(user, User): + return user.name + elif isinstance(user, str): + return user + else: + raise ValueError("invalid user type") + + def addUser(self, user: User): + self[user.name] = user + + def deleteUser(self, user): + del self[self._getUsername(user)] + + def __delitem__(self, key): + self.refresh() + if key not in self.users: + raise KeyError("User {user} doesn't exist".format(user=key)) + del self.users[key] + self.store() + + def __getitem__(self, item): + self.refresh() + return self.users[item] + + def __contains__(self, item): + self.refresh() + return item in self.users + + def __setitem__(self, key, value): + self.refresh() + if key in self.users: + raise KeyError("User {user} already exists".format(user=key)) + self.users[key] = value + self.store() + + def values(self): + self.refresh() + return self.users.values() diff --git a/openwebrx/owrx/version.py b/openwebrx/owrx/version.py new file mode 100644 index 0000000..13c9183 --- /dev/null +++ b/openwebrx/owrx/version.py @@ -0,0 +1,5 @@ +from distutils.version import LooseVersion + +_versionstring = "1.1.0" +looseversion = LooseVersion(_versionstring) +openwebrx_version = "v{0}".format(looseversion) diff --git a/openwebrx/owrx/waterfall.py b/openwebrx/owrx/waterfall.py new file mode 100644 index 0000000..c88231a --- /dev/null +++ b/openwebrx/owrx/waterfall.py @@ -0,0 +1,326 @@ +from owrx.form.input import DropdownEnum +from owrx.config import Config + + +class Waterfall(object): + def __init__(self, colors): + self.colors = colors + + def getColors(self): + return self.colors + + +class GoogleTurboWaterfall(Waterfall): + def __init__(self): + super().__init__( + [ + 0x30123B, + 0x311542, + 0x33184A, + 0x341B51, + 0x351E58, + 0x36215F, + 0x372466, + 0x38266C, + 0x392973, + 0x3A2C79, + 0x3B2F80, + 0x3C3286, + 0x3D358B, + 0x3E3891, + 0x3E3A97, + 0x3F3D9C, + 0x4040A2, + 0x4043A7, + 0x4146AC, + 0x4248B1, + 0x424BB6, + 0x434EBA, + 0x4351BF, + 0x4453C3, + 0x4456C7, + 0x4559CB, + 0x455BCF, + 0x455ED3, + 0x4561D7, + 0x4663DA, + 0x4666DD, + 0x4669E1, + 0x466BE4, + 0x466EE7, + 0x4671E9, + 0x4673EC, + 0x4676EE, + 0x4678F1, + 0x467BF3, + 0x467DF5, + 0x4680F7, + 0x4682F9, + 0x4685FA, + 0x4587FC, + 0x458AFD, + 0x448CFE, + 0x448FFE, + 0x4391FF, + 0x4294FF, + 0x4196FF, + 0x3F99FF, + 0x3E9BFF, + 0x3D9EFE, + 0x3BA1FD, + 0x3AA3FD, + 0x38A6FB, + 0x36A8FA, + 0x35ABF9, + 0x33ADF7, + 0x31B0F6, + 0x2FB2F4, + 0x2DB5F2, + 0x2CB7F0, + 0x2AB9EE, + 0x28BCEC, + 0x26BEEA, + 0x25C0E7, + 0x23C3E5, + 0x21C5E2, + 0x20C7E0, + 0x1FC9DD, + 0x1DCCDB, + 0x1CCED8, + 0x1BD0D5, + 0x1AD2D3, + 0x19D4D0, + 0x18D6CD, + 0x18D8CB, + 0x18DAC8, + 0x17DBC5, + 0x17DDC3, + 0x17DFC0, + 0x18E0BE, + 0x18E2BB, + 0x19E3B9, + 0x1AE5B7, + 0x1BE6B4, + 0x1DE8B2, + 0x1EE9AF, + 0x20EAAD, + 0x22ECAA, + 0x24EDA7, + 0x27EEA4, + 0x29EFA1, + 0x2CF09E, + 0x2FF19B, + 0x32F298, + 0x35F394, + 0x38F491, + 0x3CF58E, + 0x3FF68B, + 0x43F787, + 0x46F884, + 0x4AF980, + 0x4EFA7D, + 0x51FA79, + 0x55FB76, + 0x59FC73, + 0x5DFC6F, + 0x61FD6C, + 0x65FD69, + 0x69FE65, + 0x6DFE62, + 0x71FE5F, + 0x75FF5C, + 0x79FF59, + 0x7DFF56, + 0x80FF53, + 0x84FF50, + 0x88FF4E, + 0x8BFF4B, + 0x8FFF49, + 0x92FF46, + 0x96FF44, + 0x99FF42, + 0x9CFE40, + 0x9FFE3E, + 0xA2FD3D, + 0xA4FD3B, + 0xA7FC3A, + 0xAAFC39, + 0xACFB38, + 0xAFFA37, + 0xB1F936, + 0xB4F835, + 0xB7F835, + 0xB9F634, + 0xBCF534, + 0xBFF434, + 0xC1F334, + 0xC4F233, + 0xC6F033, + 0xC9EF34, + 0xCBEE34, + 0xCEEC34, + 0xD0EB34, + 0xD2E934, + 0xD5E835, + 0xD7E635, + 0xD9E435, + 0xDBE236, + 0xDDE136, + 0xE0DF37, + 0xE2DD37, + 0xE4DB38, + 0xE6D938, + 0xE7D738, + 0xE9D539, + 0xEBD339, + 0xEDD139, + 0xEECF3A, + 0xF0CD3A, + 0xF1CB3A, + 0xF3C93A, + 0xF4C73A, + 0xF5C53A, + 0xF7C33A, + 0xF8C13A, + 0xF9BF39, + 0xFABD39, + 0xFABA38, + 0xFBB838, + 0xFCB637, + 0xFCB436, + 0xFDB135, + 0xFDAF35, + 0xFEAC34, + 0xFEA933, + 0xFEA732, + 0xFEA431, + 0xFFA12F, + 0xFF9E2E, + 0xFF9C2D, + 0xFF992C, + 0xFE962B, + 0xFE932A, + 0xFE9028, + 0xFE8D27, + 0xFD8A26, + 0xFD8724, + 0xFC8423, + 0xFC8122, + 0xFB7E20, + 0xFB7B1F, + 0xFA781E, + 0xF9751C, + 0xF8721B, + 0xF86F1A, + 0xF76C19, + 0xF66917, + 0xF56616, + 0xF46315, + 0xF36014, + 0xF25D13, + 0xF05B11, + 0xEF5810, + 0xEE550F, + 0xED530E, + 0xEB500E, + 0xEA4E0D, + 0xE94B0C, + 0xE7490B, + 0xE6470A, + 0xE4450A, + 0xE34209, + 0xE14009, + 0xDF3E08, + 0xDE3C07, + 0xDC3A07, + 0xDA3806, + 0xD83606, + 0xD63405, + 0xD43205, + 0xD23105, + 0xD02F04, + 0xCE2D04, + 0xCC2B03, + 0xCA2903, + 0xC82803, + 0xC62602, + 0xC32402, + 0xC12302, + 0xBF2102, + 0xBC1F01, + 0xBA1E01, + 0xB71C01, + 0xB41B01, + 0xB21901, + 0xAF1801, + 0xAC1601, + 0xAA1501, + 0xA71401, + 0xA41201, + 0xA11101, + 0x9E1001, + 0x9B0F01, + 0x980D01, + 0x950C01, + 0x920B01, + 0x8E0A01, + 0x8B0901, + 0x880801, + 0x850701, + 0x810602, + 0x7E0502, + 0x7A0402, + ] + ) + + +class TeejeezWaterfall(Waterfall): + def __init__(self): + super().__init__([0x000000, 0x0000FF, 0x00FFFF, 0x00FF00, 0xFFFF00, 0xFF0000, 0xFF00FF, 0xFFFFFF]) + + +class Ha7ilmWaterfall(Waterfall): + def __init__(self): + super().__init__([0x000000, 0x2E6893, 0x69A5D0, 0x214B69, 0x9DC4E0, 0xFFF775, 0xFF8A8A, 0xB20000]) + + +class CustomWaterfall(Waterfall): + def __init__(self): + config = Config.get() + if "waterfall_colors" in config and config["waterfall_colors"]: + colors = config["waterfall_colors"] + else: + # fallback: black and white + colors = [0x000000, 0xffffff] + super().__init__(colors) + + +class WaterfallOptions(DropdownEnum): + DEFAULT = ("Google Turbo (OpenWebRX default)", GoogleTurboWaterfall) + TEEJEEZ = ("Original colorscheme by teejeez (default in OpenWebRX < 0.20)", TeejeezWaterfall) + HA7ILM = ("Old theme by HA7ILM", Ha7ilmWaterfall) + CUSTOM = ("Custom", CustomWaterfall) + + def __new__(cls, *args, **kwargs): + description, waterfallClass = args + obj = object.__new__(cls) + obj._value_ = waterfallClass.__name__ + obj.waterfallClass = waterfallClass + obj.description = description + return obj + + def __str__(self): + return self.description + + def instantiate(self): + return self.waterfallClass() + + @staticmethod + def findByColors(colors): + for o in WaterfallOptions: + if o is WaterfallOptions.CUSTOM: + continue + waterfall = o.instantiate() + if waterfall.getColors() == colors: + return o + return WaterfallOptions.CUSTOM diff --git a/openwebrx/owrx/websocket.py b/openwebrx/owrx/websocket.py new file mode 100644 index 0000000..7aa3b86 --- /dev/null +++ b/openwebrx/owrx/websocket.py @@ -0,0 +1,290 @@ +from owrx.jsons import Encoder +import base64 +import hashlib +import json +from multiprocessing import Pipe +import select +import threading +from abc import ABC, abstractmethod + +import logging + +logger = logging.getLogger(__name__) + +OPCODE_TEXT_MESSAGE = 0x01 +OPCODE_BINARY_MESSAGE = 0x02 +OPCODE_CLOSE = 0x08 +OPCODE_PING = 0x09 +OPCODE_PONG = 0x0A + + +class WebSocketException(IOError): + pass + + +class IncompleteRead(WebSocketException): + pass + + +class Drained(WebSocketException): + pass + + +class WebSocketClosed(WebSocketException): + pass + + +class Handler(ABC): + @abstractmethod + def handleTextMessage(self, connection, message: str): + pass + + @abstractmethod + def handleBinaryMessage(self, connection, data: bytes): + pass + + @abstractmethod + def handleClose(self): + pass + + +class WebSocketConnection(object): + connections = [] + + @staticmethod + def closeAll(): + for c in WebSocketConnection.connections: + try: + c.close() + except: + logger.exception("exception while shutting down websocket connections") + + def __init__(self, handler, messageHandler: Handler): + self.handler = handler + self.handler.connection.setblocking(0) + self.messageHandler = None + self.setMessageHandler(messageHandler) + (self.interruptPipeRecv, self.interruptPipeSend) = Pipe(duplex=False) + self.open = True + self.sendLock = threading.Lock() + + headers = {key.lower(): value for key, value in self.handler.headers.items()} + if "upgrade" not in headers: + raise WebSocketException("Upgrade header not found") + if headers["upgrade"].lower() != "websocket": + raise WebSocketException("Upgrade header does not contain expected value") + if "sec-websocket-key" not in headers: + raise WebSocketException("Websocket key not provided") + + ws_key = headers["sec-websocket-key"] + shakey = hashlib.sha1() + shakey.update("{ws_key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11".format(ws_key=ws_key).encode()) + ws_key_toreturn = base64.b64encode(shakey.digest()) + self.handler.wfile.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {0}\r\nCQ-CQ-de: HA5KFU\r\n\r\n".format( + ws_key_toreturn.decode() + ).encode() + ) + self.pingTimer = None + self.resetPing() + + def setMessageHandler(self, messageHandler: Handler): + self.messageHandler = messageHandler + + def get_header(self, size, opcode): + ws_first_byte = 0b10000000 | (opcode & 0x0F) + if size > 2 ** 16 - 1: + # frame size can be increased up to 2^64 by setting the size to 127 + # anything beyond that would need to be segmented into frames. i don't really think we'll need more. + return bytes( + [ + ws_first_byte, + 127, + (size >> 56) & 0xFF, + (size >> 48) & 0xFF, + (size >> 40) & 0xFF, + (size >> 32) & 0xFF, + (size >> 24) & 0xFF, + (size >> 16) & 0xFF, + (size >> 8) & 0xFF, + size & 0xFF, + ] + ) + elif size > 125: + # up to 2^16 can be sent using the extended payload size field by putting the size to 126 + return bytes([ws_first_byte, 126, (size >> 8) & 0xFF, size & 0xFF]) + else: + # 125 bytes binary message in a single unmasked frame + return bytes([ws_first_byte, size]) + + def send(self, data): + if not self.open: + raise WebSocketClosed() + # convenience + if type(data) == dict: + # allow_nan = False disallows NaN and Infinty to be encoded. Browser JSON will not parse them anyway. + data = json.dumps(data, allow_nan=False, cls=Encoder) + + # string-type messages are sent as text frames + if type(data) == str: + header = self.get_header(len(data), OPCODE_TEXT_MESSAGE) + data_to_send = header + data.encode("utf-8") + # anything else as binary + else: + header = self.get_header(len(data), OPCODE_BINARY_MESSAGE) + data_to_send = header + data + + self._sendBytes(data_to_send) + + def _sendBytes(self, data_to_send): + def chunks(input, n): + """Yield successive n-sized chunks from input.""" + for i in range(0, len(input), n): + yield input[i: i + n] + + try: + with self.sendLock: + for chunk in chunks(data_to_send, 1024): + (_, write, _) = select.select([], [self.handler.wfile], [], 10) + if self.handler.wfile in write: + written = self.handler.wfile.write(chunk) + if written != len(chunk): + logger.error("incomplete write! closing socket!") + self.close() + break + else: + logger.debug("socket not returned from select; closing") + self.close() + break + # these exception happen when the socket is closed + except OSError: + logger.exception("OSError while writing data") + self.close() + except ValueError: + logger.exception("ValueError while writing data") + self.close() + + def interrupt(self): + if self.interruptPipeSend is None: + logger.debug("interrupt with closed pipe") + return + self.interruptPipeSend.send(bytes(0x00)) + + def handle(self): + WebSocketConnection.connections.append(self) + try: + self.read_loop() + finally: + logger.debug("websocket loop ended; shutting down") + + self.messageHandler.handleClose() + self.cancelPing() + + logger.debug("websocket loop ended; sending close frame") + + header = self.get_header(0, OPCODE_CLOSE) + self._sendBytes(header) + + try: + WebSocketConnection.connections.remove(self) + except ValueError: + pass + + def read_loop(self): + def protected_read(num): + data = self.handler.rfile.read(num) + if data is None: + raise Drained() + if len(data) != num: + raise IncompleteRead() + return data + + self.open = True + while self.open: + (read, _, _) = select.select([self.interruptPipeRecv, self.handler.rfile], [], [], 15) + if self.handler.rfile in read: + available = True + self.resetPing() + while self.open and available: + try: + header = protected_read(2) + opcode = header[0] & 0x0F + length = header[1] & 0x7F + mask = (header[1] & 0x80) >> 7 + if length == 126: + header = protected_read(2) + length = (header[0] << 8) + header[1] + if mask: + masking_key = protected_read(4) + data = protected_read(length) + data = bytes([b ^ masking_key[index % 4] for (index, b) in enumerate(data)]) + else: + data = protected_read(length) + if opcode == OPCODE_TEXT_MESSAGE: + message = data.decode("utf-8") + try: + self.messageHandler.handleTextMessage(self, message) + except Exception: + logger.exception("Exception in websocket handler handleTextMessage()") + elif opcode == OPCODE_BINARY_MESSAGE: + try: + self.messageHandler.handleBinaryMessage(self, data) + except Exception: + logger.exception("Exception in websocket handler handleBinaryMessage()") + elif opcode == OPCODE_PING: + self.sendPong() + elif opcode == OPCODE_PONG: + # since every read resets the ping timer, there's nothing to do here. + pass + elif opcode == OPCODE_CLOSE: + logger.debug("websocket close frame received; closing connection") + self.open = False + else: + logger.warning("unsupported opcode: {0}".format(opcode)) + except Drained: + available = False + except IncompleteRead: + logger.warning("incomplete read on websocket; closing connection") + self.open = False + except OSError: + logger.exception("OSError while reading data; closing connection") + self.open = False + + self.interruptPipeSend.close() + self.interruptPipeSend = None + # drain messages left in the queue so that the queue can be successfully closed + # this is necessary since python keeps the file descriptors open otherwise + try: + while True: + self.interruptPipeRecv.recv() + except EOFError: + pass + self.interruptPipeRecv.close() + self.interruptPipeRecv = None + + def close(self): + if not self.open: + return + self.open = False + self.interrupt() + + def cancelPing(self): + if self.pingTimer: + self.pingTimer.cancel() + + def resetPing(self): + self.cancelPing() + if not self.open: + logger.debug("resetPing() while closed. passing...") + return + self.pingTimer = threading.Timer(30, self.sendPing) + self.pingTimer.start() + + def sendPing(self): + header = self.get_header(0, OPCODE_PING) + self._sendBytes(header) + self.resetPing() + + def sendPong(self): + header = self.get_header(0, OPCODE_PONG) + self._sendBytes(header) diff --git a/openwebrx/owrx/wsjt.py b/openwebrx/owrx/wsjt.py new file mode 100644 index 0000000..0693046 --- /dev/null +++ b/openwebrx/owrx/wsjt.py @@ -0,0 +1,399 @@ +from datetime import datetime, timezone +from typing import List + +from owrx.map import Map, LocatorLocation +import re +from owrx.metrics import Metrics, CounterMetric +from owrx.reporting import ReportingEngine +from owrx.parser import Parser +from owrx.audio import AudioChopperProfile, StaticProfileSource, ConfigWiredProfileSource +from abc import ABC, ABCMeta, abstractmethod +from owrx.config import Config +from enum import Enum + +import logging + +logger = logging.getLogger(__name__) + + +class WsjtProfile(AudioChopperProfile, metaclass=ABCMeta): + def decoding_depth(self): + pm = Config.get() + mode = self.getMode().lower() + # mode-specific setting? + if "wsjt_decoding_depths" in pm and mode in pm["wsjt_decoding_depths"]: + return pm["wsjt_decoding_depths"][mode] + # return global default + if "wsjt_decoding_depth" in pm: + return pm["wsjt_decoding_depth"] + # default when no setting is provided + return 3 + + def getTimestampFormat(self): + if self.getInterval() < 60: + return "%H%M%S" + return "%H%M" + + def getFileTimestampFormat(self): + return "%y%m%d_" + self.getTimestampFormat() + + @abstractmethod + def getMode(self): + pass + + +class Fst4ProfileSource(ConfigWiredProfileSource): + def getPropertiesToWire(self) -> List[str]: + return ["fst4_enabled_intervals"] + + def getProfiles(self) -> List[AudioChopperProfile]: + config = Config.get() + profiles = config["fst4_enabled_intervals"] if "fst4_enabled_intervals" in config else [] + return [Fst4Profile(i) for i in profiles if i in Fst4Profile.availableIntervals] + + +class Fst4wProfileSource(ConfigWiredProfileSource): + def getPropertiesToWire(self) -> List[str]: + return ["fst4w_enabled_intervals"] + + def getProfiles(self) -> List[AudioChopperProfile]: + config = Config.get() + profiles = config["fst4w_enabled_intervals"] if "fst4w_enabled_intervals" in config else [] + return [Fst4wProfile(i) for i in profiles if i in Fst4wProfile.availableIntervals] + + +class Q65ProfileSource(ConfigWiredProfileSource): + def getPropertiesToWire(self) -> List[str]: + return ["q65_enabled_combinations"] + + def getProfiles(self) -> List[AudioChopperProfile]: + config = Config.get() + profiles = config["q65_enabled_combinations"] if "q65_enabled_combinations" in config else [] + + def buildProfile(modestring): + try: + mode = Q65Mode[modestring[0]] + interval = Q65Interval(int(modestring[1:])) + if interval.is_available(mode): + return Q65Profile(interval, mode) + except (ValueError, KeyError): + pass + logger.warning('"%s" is not a valid Q65 mode, or an invalid mode string, ignoring', modestring) + return None + + mapped = [buildProfile(m) for m in profiles] + return [p for p in mapped if p is not None] + + +class WsjtProfiles(object): + @staticmethod + def getSource(mode: str): + if mode == "ft8": + return StaticProfileSource([Ft8Profile()]) + elif mode == "wspr": + return StaticProfileSource([WsprProfile()]) + elif mode == "jt65": + return StaticProfileSource([Jt65Profile()]) + elif mode == "jt9": + return StaticProfileSource([Jt9Profile()]) + elif mode == "ft4": + return StaticProfileSource([Ft4Profile()]) + elif mode == "fst4": + return Fst4ProfileSource() + elif mode == "fst4w": + return Fst4wProfileSource() + elif mode == "q65": + return Q65ProfileSource() + + +class Ft8Profile(WsjtProfile): + def getInterval(self): + return 15 + + def decoder_commandline(self, file): + return ["jt9", "--ft8", "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "FT8" + + +class WsprProfile(WsjtProfile): + def getInterval(self): + return 120 + + def decoder_commandline(self, file): + cmd = ["wsprd"] + if self.decoding_depth() > 1: + cmd += ["-d"] + cmd += [file] + return cmd + + def getMode(self): + return "WSPR" + + +class Jt65Profile(WsjtProfile): + def getInterval(self): + return 60 + + def decoder_commandline(self, file): + return ["jt9", "--jt65", "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "JT65" + + +class Jt9Profile(WsjtProfile): + def getInterval(self): + return 60 + + def decoder_commandline(self, file): + return ["jt9", "--jt9", "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "JT9" + + +class Ft4Profile(WsjtProfile): + def getInterval(self): + return 7.5 + + def decoder_commandline(self, file): + return ["jt9", "--ft4", "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "FT4" + + +class Fst4Profile(WsjtProfile): + availableIntervals = [15, 30, 60, 120, 300, 900, 1800] + + def __init__(self, interval): + self.interval = interval + + def getInterval(self): + return self.interval + + def decoder_commandline(self, file): + return ["jt9", "--fst4", "-p", str(self.interval), "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "FST4" + + +class Fst4wProfile(WsjtProfile): + availableIntervals = [120, 300, 900, 1800] + + def __init__(self, interval): + self.interval = interval + + def getInterval(self): + return self.interval + + def decoder_commandline(self, file): + return ["jt9", "--fst4w", "-p", str(self.interval), "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "FST4W" + + +class Q65Mode(Enum): + # value is the bandwidth multiplier according to https://physics.princeton.edu/pulsar/k1jt/Q65_Quick_Start.pdf + A = 1 + B = 2 + C = 4 + D = 8 + E = 16 + + def is_available(self, interval: "Q65Interval"): + return interval.is_available(self) + + +class Q65Interval(Enum): + # (interval, occupied bandwidth in mode "A") + # according to https://physics.princeton.edu/pulsar/k1jt/Q65_Quick_Start.pdf + INTERVAL_15 = (15, 433) + INTERVAL_30 = (30, 217) + INTERVAL_60 = (60, 108) + INTERVAL_120 = (120, 49) + INTERVAL_300 = (300, 19) + + def __new__(cls, *args, **kwargs): + interval, occupied_bandwidth = args + obj = object.__new__(cls) + obj._value_ = interval + obj.occupied_bandwidth = occupied_bandwidth + return obj + + def is_available(self, mode: Q65Mode): + # total bandwidth must not exceed the typical SSB bandwidth + return self.occupied_bandwidth * mode.value < 2700 + + +class Q65Profile(WsjtProfile): + def __init__(self, interval: Q65Interval, mode: Q65Mode): + self.interval = interval.value + self.mode = mode + + def getMode(self): + return "Q65" + + def getInterval(self): + return self.interval + + def decoder_commandline(self, file): + return ["jt9", "--q65", "-p", str(self.interval), "-b", self.mode.name, "-d", str(self.decoding_depth()), file] + + +class WsjtParser(Parser): + def parse(self, data): + try: + profile, freq, raw_msg = data + self.setDialFrequency(freq) + msg = raw_msg.decode().rstrip() + # known debug messages we know to skip + if msg.startswith(""): + return + if msg.startswith(" EOF on input file"): + return + + mode = profile.getMode() + if mode in ["WSPR", "FST4W"]: + messageParser = BeaconMessageParser() + else: + messageParser = QsoMessageParser() + if mode == "WSPR": + decoder = WsprDecoder(profile, messageParser) + else: + decoder = Jt9Decoder(profile, messageParser) + out = decoder.parse(msg, freq) + if isinstance(profile, Q65Profile) and not out["msg"]: + # all efforts in vain, it's just a potential signal indicator + return + out["mode"] = mode + out["interval"] = profile.getInterval() + + self.pushDecode(mode) + if "callsign" in out and "locator" in out: + Map.getSharedInstance().updateLocation( + out["callsign"], LocatorLocation(out["locator"]), mode, self.band + ) + ReportingEngine.getSharedInstance().spot(out) + + self.handler.write_wsjt_message(out) + except Exception: + logger.exception("Exception while parsing wsjt message") + + def pushDecode(self, mode): + metrics = Metrics.getSharedInstance() + band = "unknown" + if self.band is not None: + band = self.band.getName() + if band is None: + band = "unknown" + + if mode is None: + mode = "unknown" + + name = "wsjt.decodes.{band}.{mode}".format(band=band, mode=mode) + metric = metrics.getMetric(name) + if metric is None: + metric = CounterMetric() + metrics.addMetric(name, metric) + + metric.inc() + + +class Decoder(ABC): + def __init__(self, profile, messageParser): + self.profile = profile + self.messageParser = messageParser + + def parse_timestamp(self, instring): + dateformat = self.profile.getTimestampFormat() + remain = instring[len(dateformat) + 1 :] + try: + ts = datetime.strptime(instring[0 : len(dateformat)], dateformat) + return remain, int( + datetime.combine(datetime.utcnow().date(), ts.time()).replace(tzinfo=timezone.utc).timestamp() * 1000 + ) + except ValueError: + return remain, None + + @abstractmethod + def parse(self, msg, dial_freq): + pass + + +class MessageParser(ABC): + @abstractmethod + def parse(self, msg): + pass + + +# Used in QSO-style modes (FT8, FT4, FST4) +class QsoMessageParser(MessageParser): + locator_pattern = re.compile(".*\\s([A-Z0-9/]{2,})(\\sR)?\\s([A-R]{2}[0-9]{2})$") + + def parse(self, msg): + m = QsoMessageParser.locator_pattern.match(msg) + if m is None: + return {} + # this is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very + # likely this just means roger roger goodbye. + if m.group(3) == "RR73": + return {"callsign": m.group(1)} + return {"callsign": m.group(1), "locator": m.group(3)} + + +# Used in propagation reporting / beacon modes (WSPR / FST4W) +class BeaconMessageParser(MessageParser): + wspr_splitter_pattern = re.compile("([A-Z0-9/]*)\\s([A-R]{2}[0-9]{2})\\s([0-9]+)") + + def parse(self, msg): + m = BeaconMessageParser.wspr_splitter_pattern.match(msg) + if m is None: + return {} + return {"callsign": m.group(1), "locator": m.group(2), "dbm": m.group(3)} + + +class Jt9Decoder(Decoder): + def parse(self, msg, dial_freq): + # ft8 sample + # '222100 -15 -0.0 508 ~ CQ EA7MJ IM66' + # jt65 sample + # '2352 -7 0.4 1801 # R0WAS R2ABM KO85' + # '0003 -4 0.4 1762 # CQ R2ABM KO85' + # fst4 sample + # '**** -23 0.6 3023 ` <...> <...> R 591631 BI53PV' + msg, timestamp = self.parse_timestamp(msg) + wsjt_msg = msg[17:53].strip() + + result = { + "timestamp": timestamp, + "db": float(msg[0:3]), + "dt": float(msg[4:8]), + "freq": dial_freq + int(msg[9:13]), + "msg": wsjt_msg, + } + result.update(self.messageParser.parse(wsjt_msg)) + return result + + +class WsprDecoder(Decoder): + def parse(self, msg, dial_freq): + # wspr sample + # '2600 -24 0.4 0.001492 -1 G8AXA JO01 33' + # '0052 -29 2.6 0.001486 0 G02CWT IO92 23' + msg, timestamp = self.parse_timestamp(msg) + wsjt_msg = msg[24:].strip() + result = { + "timestamp": timestamp, + "db": float(msg[0:3]), + "dt": float(msg[4:8]), + "freq": dial_freq + int(float(msg[10:20]) * 1e6), + "drift": int(msg[20:23]), + "msg": wsjt_msg, + } + result.update(self.messageParser.parse(wsjt_msg)) + return result diff --git a/openwebrx/setup.py b/openwebrx/setup.py new file mode 100644 index 0000000..9c11425 --- /dev/null +++ b/openwebrx/setup.py @@ -0,0 +1,42 @@ +from glob import glob +from setuptools import setup +from owrx.version import looseversion + +try: + from setuptools import find_namespace_packages +except ImportError: + from setuptools import PEP420PackageFinder + + find_namespace_packages = PEP420PackageFinder.find + +setup( + name="OpenWebRX", + version=str(looseversion), + packages=find_namespace_packages( + include=[ + "owrx", + "owrx.source", + "owrx.service", + "owrx.controllers", + "owrx.controllers.settings", + "owrx.property", + "owrx.form", + "owrx.form.input", + "owrx.config", + "owrx.reporting", + "owrx.audio", + "owrx.admin", + "csdr", + "htdocs", + ] + ), + package_data={"htdocs": [f[len("htdocs/") :] for f in glob("htdocs/**/*", recursive=True)]}, + entry_points={"console_scripts": ["openwebrx=owrx.__main__:main"]}, + url="https://www.openwebrx.de/", + author="Jakob Ketterl", + author_email="jakob.ketterl@gmx.de", + maintainer="Jakob Ketterl", + maintainer_email="jakob.ketterl@gmx.de", + license="GAGPL", + python_requires=">=3.5", +) diff --git a/openwebrx/systemd/openwebrx.service b/openwebrx/systemd/openwebrx.service new file mode 100644 index 0000000..8b87833 --- /dev/null +++ b/openwebrx/systemd/openwebrx.service @@ -0,0 +1,12 @@ +[Unit] +Description=OpenWebRX WebSDR receiver + +[Service] +Type=simple +User=openwebrx +Group=openwebrx +ExecStart=/usr/bin/openwebrx +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/openwebrx/test/__init__.py b/openwebrx/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/test/property/__init__.py b/openwebrx/test/property/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/test/property/filter/__init__.py b/openwebrx/test/property/filter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/test/property/filter/test_by_lambda.py b/openwebrx/test/property/filter/test_by_lambda.py new file mode 100644 index 0000000..14c6daf --- /dev/null +++ b/openwebrx/test/property/filter/test_by_lambda.py @@ -0,0 +1,17 @@ +from owrx.property.filter import ByLambda +from unittest import TestCase +from unittest.mock import Mock + + +class TestByLambda(TestCase): + def testPositive(self): + mock = Mock(return_value=True) + filter = ByLambda(mock) + self.assertTrue(filter.apply("test_key")) + mock.assert_called_with("test_key") + + def testNegateive(self): + mock = Mock(return_value=False) + filter = ByLambda(mock) + self.assertFalse(filter.apply("test_key")) + mock.assert_called_with("test_key") diff --git a/openwebrx/test/property/filter/test_by_property_name.py b/openwebrx/test/property/filter/test_by_property_name.py new file mode 100644 index 0000000..098f69b --- /dev/null +++ b/openwebrx/test/property/filter/test_by_property_name.py @@ -0,0 +1,12 @@ +from owrx.property.filter import ByPropertyName +from unittest import TestCase + + +class ByPropertyNameTest(TestCase): + def testNameIsInList(self): + filter = ByPropertyName("test_key") + self.assertTrue(filter.apply("test_key")) + + def testNameNotInList(self): + filter = ByPropertyName("test_key") + self.assertFalse(filter.apply("other_key")) diff --git a/openwebrx/test/property/test_property_carousel.py b/openwebrx/test/property/test_property_carousel.py new file mode 100644 index 0000000..bc0c837 --- /dev/null +++ b/openwebrx/test/property/test_property_carousel.py @@ -0,0 +1,125 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property import PropertyCarousel, PropertyLayer, PropertyDeleted, PropertyWriteError + + +class PropertyCarouselTest(TestCase): + def testInitiallyEmpty(self): + pc = PropertyCarousel() + with self.assertRaises(KeyError): + x = pc["testkey"] + + def testPropertyAccess(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("test", pl) + pc.switch("test") + self.assertEqual(pc["testkey"], "testvalue") + + def testWriteAccess(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="old_value") + pc.addLayer("test", pl) + pc.switch("test") + pc["testkey"] = "new_value" + self.assertEqual(pc["testkey"], "new_value") + self.assertEqual(pl["testkey"], "new_value") + + def testForwardsEvents(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="old_value") + pc.addLayer("test", pl) + pc.switch("test") + mock = Mock() + pc.wire(mock.method) + pc["testkey"] = "new_value" + mock.method.assert_called_once_with({"testkey": "new_value"}) + + def testStopsForwardingAfterSwitch(self): + pc = PropertyCarousel() + pl_x = PropertyLayer(testkey="old_value") + pc.addLayer("x", pl_x) + pl_y = PropertyLayer(testkey="new_value") + pc.addLayer("y", pl_y) + pc.switch("x") + pc.switch("y") + mock = Mock() + pc.wire(mock.method) + pl_x["testkey"] = "new_value" + mock.method.assert_not_called() + + def testEventsOnSwitch(self): + pc = PropertyCarousel() + pl_x = PropertyLayer(old_key="old_value") + pc.addLayer("x", pl_x) + pl_y = PropertyLayer(new_key="new_value") + pc.addLayer("y", pl_y) + pc.switch("x") + mock = Mock() + pc.wire(mock.method) + pc.switch("y") + mock.method.assert_called_once_with({"old_key": PropertyDeleted, "new_key": "new_value"}) + + def testNoEventsIfKeysDontChange(self): + pc = PropertyCarousel() + pl_x = PropertyLayer(testkey="same_value") + pc.addLayer("x", pl_x) + pl_y = PropertyLayer(testkey="same_value") + pc.addLayer("y", pl_y) + pc.switch("x") + mock = Mock() + pc.wire(mock.method) + pc.switch("y") + mock.method.assert_not_called() + + def testKeyErrorOnInvalidSwitch(self): + pc = PropertyCarousel() + with self.assertRaises(KeyError): + pc.switch("doesntmatter") + + def testRemoveLayer(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("x", pl) + pc.switch("x") + self.assertEqual(pc["testkey"], "testvalue") + pc.removeLayer("x") + with self.assertRaises(KeyError): + pc.switch("x") + + def testPropertyResetAfterRemoval(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("x", pl) + pc.switch("x") + self.assertEqual(pc["testkey"], "testvalue") + pc.removeLayer("x") + with self.assertRaises(KeyError): + x = pc["testkey"] + + def testEmptySwitch(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("x", pl) + pc.switch("x") + self.assertEqual(pc["testkey"], "testvalue") + pc.switch() + with self.assertRaises(KeyError): + x = pc["testkey"] + + def testErrorOnWriteOnDefaultLayer(self): + pc = PropertyCarousel() + with self.assertRaises(PropertyWriteError): + pc["testkey"] = "testvalue" + + def testSendsChangesIfActiveLayerIsReplaced(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("x", pl) + pc.switch("x") + self.assertEqual(pc["testkey"], "testvalue") + mock = Mock() + pc.wire(mock.method) + pl = PropertyLayer(testkey="othervalue") + pc.addLayer("x", pl) + mock.method.assert_called_once_with({"testkey": "othervalue"}) diff --git a/openwebrx/test/property/test_property_deletion.py b/openwebrx/test/property/test_property_deletion.py new file mode 100644 index 0000000..b68cf08 --- /dev/null +++ b/openwebrx/test/property/test_property_deletion.py @@ -0,0 +1,8 @@ +from unittest import TestCase +from owrx.property import PropertyDeletion + + +class PropertyDeletionTest(TestCase): + def testDeletionEvaluatesToFalse(self): + deletion = PropertyDeletion() + self.assertFalse(deletion) diff --git a/openwebrx/test/property/test_property_filter.py b/openwebrx/test/property/test_property_filter.py new file mode 100644 index 0000000..82f5961 --- /dev/null +++ b/openwebrx/test/property/test_property_filter.py @@ -0,0 +1,82 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property import PropertyLayer, PropertyFilter, PropertyDeleted + + +class PropertyFilterTest(TestCase): + def testPassesProperty(self): + pm = PropertyLayer() + pm["testkey"] = "testvalue" + mock = Mock() + mock.apply.return_value = True + pf = PropertyFilter(pm, mock) + self.assertEqual(pf["testkey"], "testvalue") + + def testMissesProperty(self): + pm = PropertyLayer() + pm["testkey"] = "testvalue" + mock = Mock() + mock.apply.return_value = False + pf = PropertyFilter(pm, mock) + self.assertFalse("testkey" in pf) + with self.assertRaises(KeyError): + x = pf["testkey"] + + def testForwardsEvent(self): + pm = PropertyLayer() + mock = Mock() + mock.apply.return_value = True + pf = PropertyFilter(pm, mock) + mock = Mock() + pf.wire(mock.method) + pm["testkey"] = "testvalue" + mock.method.assert_called_once_with({"testkey": "testvalue"}) + + def testForwardsPropertyEvent(self): + pm = PropertyLayer() + mock = Mock() + mock.apply.return_value = True + pf = PropertyFilter(pm, mock) + mock = Mock() + pf.wireProperty("testkey", mock.method) + pm["testkey"] = "testvalue" + mock.method.assert_called_once_with("testvalue") + + def testForwardsWrite(self): + pm = PropertyLayer() + mock = Mock() + mock.apply.return_value = True + pf = PropertyFilter(pm, mock) + pf["testkey"] = "testvalue" + self.assertTrue("testkey" in pm) + self.assertEqual(pm["testkey"], "testvalue") + + def testOverwrite(self): + pm = PropertyLayer() + pm["testkey"] = "old value" + mock = Mock() + mock.apply.return_value = True + pf = PropertyFilter(pm, mock) + pf["testkey"] = "new value" + self.assertEqual(pm["testkey"], "new value") + self.assertEqual(pf["testkey"], "new value") + + def testRejectsWrite(self): + pm = PropertyLayer() + pm["testkey"] = "old value" + mock = Mock() + mock.apply.return_value = False + pf = PropertyFilter(pm, mock) + with self.assertRaises(KeyError): + pf["testkey"] = "new value" + self.assertEqual(pm["testkey"], "old value") + + def testPropagatesDeletion(self): + pm = PropertyLayer(testkey="somevalue") + filter_mock = Mock() + filter_mock.apply.return_value = True + pf = PropertyFilter(pm, filter_mock) + mock = Mock() + pf.wire(mock.method) + del pf["testkey"] + mock.method.assert_called_once_with({"testkey": PropertyDeleted}) diff --git a/openwebrx/test/property/test_property_layer.py b/openwebrx/test/property/test_property_layer.py new file mode 100644 index 0000000..f07ae1b --- /dev/null +++ b/openwebrx/test/property/test_property_layer.py @@ -0,0 +1,93 @@ +from owrx.property import PropertyLayer, PropertyDeleted +from unittest import TestCase +from unittest.mock import Mock + + +class PropertyLayerTest(TestCase): + def testCreationWithKwArgs(self): + pm = PropertyLayer(testkey="value") + self.assertEqual(pm["testkey"], "value") + + # this should be synonymous, so this is rather for illustration purposes + contents = {"testkey": "value"} + pm = PropertyLayer(**contents) + self.assertEqual(pm["testkey"], "value") + + def testKeyIsset(self): + pm = PropertyLayer() + self.assertFalse("some_key" in pm) + + def testKeyError(self): + pm = PropertyLayer() + with self.assertRaises(KeyError): + x = pm["some_key"] + + def testSubscription(self): + pm = PropertyLayer() + pm["testkey"] = "before" + mock = Mock() + pm.wire(mock.method) + pm["testkey"] = "after" + mock.method.assert_called_once_with({"testkey": "after"}) + + def testUnsubscribe(self): + pm = PropertyLayer() + pm["testkey"] = "before" + mock = Mock() + sub = pm.wire(mock.method) + pm["testkey"] = "between" + mock.method.assert_called_once_with({"testkey": "between"}) + + mock.reset_mock() + pm.unwire(sub) + pm["testkey"] = "after" + mock.method.assert_not_called() + + def testContains(self): + pm = PropertyLayer() + pm["testkey"] = "value" + self.assertTrue("testkey" in pm) + + def testDoesNotContain(self): + pm = PropertyLayer() + self.assertFalse("testkey" in pm) + + def testSubscribeBeforeSet(self): + pm = PropertyLayer() + mock = Mock() + pm.wireProperty("testkey", mock.method) + mock.method.assert_not_called() + pm["testkey"] = "newvalue" + mock.method.assert_called_once_with("newvalue") + + def testEventPreventedWhenValueUnchanged(self): + pm = PropertyLayer() + pm["testkey"] = "testvalue" + mock = Mock() + pm.wire(mock.method) + pm["testkey"] = "testvalue" + mock.method.assert_not_called() + + def testDeletionIsSent(self): + pm = PropertyLayer(testkey="somevalue") + mock = Mock() + pm.wireProperty("testkey", mock.method) + mock.method.reset_mock() + del pm["testkey"] + mock.method.assert_called_once_with(PropertyDeleted) + + def testDeletionInGeneralWiring(self): + pm = PropertyLayer(testkey="somevalue") + mock = Mock() + pm.wire(mock.method) + del pm["testkey"] + mock.method.assert_called_once_with({"testkey": PropertyDeleted}) + + def testNoDeletionEventWhenPropertyDoesntExist(self): + pm = PropertyLayer(otherkey="somevalue") + mock = Mock() + pm.wireProperty("testkey", mock.method) + mock.method.reset_mock() + with self.assertRaises(KeyError): + del pm["testkey"] + mock.method.assert_not_called() diff --git a/openwebrx/test/property/test_property_readonly.py b/openwebrx/test/property/test_property_readonly.py new file mode 100644 index 0000000..09d57ee --- /dev/null +++ b/openwebrx/test/property/test_property_readonly.py @@ -0,0 +1,23 @@ +from unittest import TestCase +from owrx.property import PropertyLayer, PropertyReadOnly, PropertyWriteError + + +class PropertyReadOnlyTest(TestCase): + def testPreventsWrites(self): + layer = PropertyLayer() + layer["testkey"] = "initial value" + ro = PropertyReadOnly(layer) + with self.assertRaises(PropertyWriteError): + ro["testkey"] = "new value" + with self.assertRaises(PropertyWriteError): + ro["otherkey"] = "testvalue" + self.assertEqual(ro["testkey"], "initial value") + self.assertNotIn("otherkey", ro) + + def testPreventsDeletes(self): + layer = PropertyLayer(testkey="some value") + ro = PropertyReadOnly(layer) + with self.assertRaises(PropertyWriteError): + del ro["testkey"] + self.assertEqual(ro["testkey"], "some value") + self.assertEqual(layer["testkey"], "some value") diff --git a/openwebrx/test/property/test_property_stack.py b/openwebrx/test/property/test_property_stack.py new file mode 100644 index 0000000..860f13f --- /dev/null +++ b/openwebrx/test/property/test_property_stack.py @@ -0,0 +1,240 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property import PropertyLayer, PropertyStack, PropertyDeleted + + +class PropertyStackTest(TestCase): + def testLayer(self): + om = PropertyStack() + pm = PropertyLayer() + pm["testkey"] = "testvalue" + om.addLayer(1, pm) + self.assertEqual(om["testkey"], "testvalue") + + def testHighPriority(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + high_pm["testkey"] = "high value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + self.assertEqual(om["testkey"], "high value") + + def testPriorityFallback(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + self.assertEqual(om["testkey"], "low value") + + def testLayerRemoval(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + high_pm["testkey"] = "high value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + self.assertEqual(om["testkey"], "high value") + om.removeLayer(high_pm) + self.assertEqual(om["testkey"], "low value") + + def testLayerRemovalByPriority(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + high_pm["testkey"] = "high value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + self.assertEqual(om["testkey"], "high value") + om.removeLayerByPriority(0) + self.assertEqual(om["testkey"], "low value") + + def testPropertyChange(self): + layer = PropertyLayer() + stack = PropertyStack() + stack.addLayer(0, layer) + mock = Mock() + stack.wire(mock.method) + layer["testkey"] = "testvalue" + mock.method.assert_called_once_with({"testkey": "testvalue"}) + + def testPropertyChangeEventPriority(self): + low_layer = PropertyLayer() + high_layer = PropertyLayer() + low_layer["testkey"] = "initial low value" + high_layer["testkey"] = "initial high value" + stack = PropertyStack() + stack.addLayer(1, low_layer) + stack.addLayer(0, high_layer) + mock = Mock() + stack.wire(mock.method) + low_layer["testkey"] = "modified low value" + mock.method.assert_not_called() + high_layer["testkey"] = "modified high value" + mock.method.assert_called_once_with({"testkey": "modified high value"}) + + def testPropertyEventOnLayerAdd(self): + low_layer = PropertyLayer() + low_layer["testkey"] = "low value" + stack = PropertyStack() + stack.addLayer(1, low_layer) + mock = Mock() + stack.wireProperty("testkey", mock.method) + mock.reset_mock() + high_layer = PropertyLayer() + high_layer["testkey"] = "high value" + stack.addLayer(0, high_layer) + mock.method.assert_called_once_with("high value") + + def testNoEventOnExistingValue(self): + low_layer = PropertyLayer() + low_layer["testkey"] = "same value" + stack = PropertyStack() + stack.addLayer(1, low_layer) + mock = Mock() + stack.wireProperty("testkey", mock.method) + mock.reset_mock() + high_layer = PropertyLayer() + high_layer["testkey"] = "same value" + stack.addLayer(0, high_layer) + mock.method.assert_not_called() + + def testEventOnLayerWithNewProperty(self): + low_layer = PropertyLayer() + low_layer["existingkey"] = "existing value" + stack = PropertyStack() + stack.addLayer(1, low_layer) + mock = Mock() + stack.wireProperty("newkey", mock.method) + high_layer = PropertyLayer() + high_layer["newkey"] = "new value" + stack.addLayer(0, high_layer) + mock.method.assert_called_once_with("new value") + + def testEventOnLayerRemoval(self): + low_layer = PropertyLayer() + high_layer = PropertyLayer() + stack = PropertyStack() + stack.addLayer(1, low_layer) + stack.addLayer(0, high_layer) + low_layer["testkey"] = "low value" + high_layer["testkey"] = "high value" + + mock = Mock() + stack.wireProperty("testkey", mock.method) + mock.method.assert_called_once_with("high value") + mock.reset_mock() + stack.removeLayer(high_layer) + mock.method.assert_called_once_with("low value") + + def testNoneOnKeyRemoval(self): + low_layer = PropertyLayer() + high_layer = PropertyLayer() + stack = PropertyStack() + stack.addLayer(1, low_layer) + stack.addLayer(0, high_layer) + low_layer["testkey"] = "low value" + high_layer["testkey"] = "high value" + high_layer["unique key"] = "unique value" + + mock = Mock() + stack.wireProperty("unique key", mock.method) + mock.method.assert_called_once_with("unique value") + mock.reset_mock() + stack.removeLayer(high_layer) + mock.method.assert_called_once_with(PropertyDeleted) + + def testReplaceLayer(self): + first_layer = PropertyLayer() + first_layer["testkey"] = "old value" + second_layer = PropertyLayer() + second_layer["testkey"] = "new value" + + stack = PropertyStack() + stack.addLayer(0, first_layer) + + mock = Mock() + stack.wireProperty("testkey", mock.method) + mock.method.assert_called_once_with("old value") + mock.reset_mock() + + stack.replaceLayer(0, second_layer) + mock.method.assert_called_once_with("new value") + + def testUnwiresEventsOnRemoval(self): + layer = PropertyLayer() + layer["testkey"] = "before" + stack = PropertyStack() + stack.addLayer(0, layer) + mock = Mock() + stack.wire(mock.method) + stack.removeLayer(layer) + mock.method.assert_called_once_with({"testkey": PropertyDeleted}) + mock.reset_mock() + + layer["testkey"] = "after" + mock.method.assert_not_called() + + def testReplaceLayerNoEventWhenValueUnchanged(self): + fixed = PropertyLayer() + fixed["testkey"] = "fixed value" + first_layer = PropertyLayer() + first_layer["testkey"] = "same value" + second_layer = PropertyLayer() + second_layer["testkey"] = "same value" + + stack = PropertyStack() + stack.addLayer(1, fixed) + stack.addLayer(0, first_layer) + mock = Mock() + stack.wire(mock.method) + mock.method.assert_not_called() + + stack.replaceLayer(0, second_layer) + mock.method.assert_not_called() + + def testWritesToExpectedLayer(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + om["testkey"] = "new value" + self.assertEqual(low_pm["testkey"], "new value") + + def testDeletionEvent(self): + ps = PropertyStack() + pm = PropertyLayer(testkey="testvalue") + ps.addLayer(0, pm) + mock = Mock() + ps.wire(mock.method) + del ps["testkey"] + mock.method.assert_called_once_with({"testkey": PropertyDeleted}) + + def testDeletionWithSecondLayer(self): + ps = PropertyStack() + low_pm = PropertyLayer(testkey="testvalue") + high_pm = PropertyLayer() + ps.addLayer(0, high_pm) + ps.addLayer(1, low_pm) + mock = Mock() + ps.wire(mock.method) + del low_pm["testkey"] + mock.method.assert_called_once_with({"testkey": PropertyDeleted}) + + def testChangeEventWhenKeyDeleted(self): + ps = PropertyStack() + low_pm = PropertyLayer(testkey="lowvalue") + high_pm = PropertyLayer(testkey="highvalue") + ps.addLayer(0, high_pm) + ps.addLayer(1, low_pm) + mock = Mock() + ps.wire(mock.method) + del high_pm["testkey"] + mock.method.assert_called_once_with({"testkey": "lowvalue"}) diff --git a/openwebrx/test/property/test_property_validator.py b/openwebrx/test/property/test_property_validator.py new file mode 100644 index 0000000..a246031 --- /dev/null +++ b/openwebrx/test/property/test_property_validator.py @@ -0,0 +1,37 @@ +from unittest import TestCase +from owrx.property import PropertyLayer, PropertyValidator, PropertyValidationError +from owrx.property.validators import NumberValidator, StringValidator + + +class PropertyValidatorTest(TestCase): + def testPassesUnvalidated(self): + pm = PropertyLayer() + pv = PropertyValidator(pm) + pv["testkey"] = "testvalue" + self.assertEqual(pv["testkey"], "testvalue") + self.assertEqual(pm["testkey"], "testvalue") + + def testPassesValidValue(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": NumberValidator()}) + pv["testkey"] = 42 + self.assertEqual(pv["testkey"], 42) + + def testThrowsErrorOnInvalidValue(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": NumberValidator()}) + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" + + def testSetValidator(self): + pv = PropertyValidator(PropertyLayer()) + pv.setValidator("testkey", NumberValidator()) + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" + + def testUpdateValidator(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": StringValidator()}) + # this should pass + pv["testkey"] = "text" + pv.setValidator("testkey", NumberValidator()) + # this should raise + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" diff --git a/openwebrx/test/property/validators/__init__.py b/openwebrx/test/property/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/test/property/validators/test_bool_validator.py b/openwebrx/test/property/validators/test_bool_validator.py new file mode 100644 index 0000000..08cfea6 --- /dev/null +++ b/openwebrx/test/property/validators/test_bool_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import BoolValidator + + +class NumberValidatorTest(TestCase): + def testPassesNumbers(self): + validator = BoolValidator() + self.assertTrue(validator.isValid(True)) + self.assertTrue(validator.isValid(False)) + + def testDoesntPassOthers(self): + validator = BoolValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/openwebrx/test/property/validators/test_float_validator.py b/openwebrx/test/property/validators/test_float_validator.py new file mode 100644 index 0000000..f4e43ec --- /dev/null +++ b/openwebrx/test/property/validators/test_float_validator.py @@ -0,0 +1,15 @@ +from unittest import TestCase +from owrx.property.validators import FloatValidator + + +class FloatValidatorTest(TestCase): + def testPassesNumbers(self): + validator = FloatValidator() + self.assertTrue(validator.isValid(.5)) + + def testDoesntPassOthers(self): + validator = FloatValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/openwebrx/test/property/validators/test_integer_validator.py b/openwebrx/test/property/validators/test_integer_validator.py new file mode 100644 index 0000000..454918a --- /dev/null +++ b/openwebrx/test/property/validators/test_integer_validator.py @@ -0,0 +1,15 @@ +from unittest import TestCase +from owrx.property.validators import IntegerValidator + + +class IntegerValidatorTest(TestCase): + def testPassesIntegers(self): + validator = IntegerValidator() + self.assertTrue(validator.isValid(123)) + self.assertTrue(validator.isValid(-2)) + + def testDoesntPassOthers(self): + validator = IntegerValidator() + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/openwebrx/test/property/validators/test_lambda_validator.py b/openwebrx/test/property/validators/test_lambda_validator.py new file mode 100644 index 0000000..969dce6 --- /dev/null +++ b/openwebrx/test/property/validators/test_lambda_validator.py @@ -0,0 +1,21 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property.validators import LambdaValidator + + +class LambdaValidatorTest(TestCase): + def testPassesValue(self): + mock = Mock() + validator = LambdaValidator(mock.method) + validator.isValid("test") + mock.method.assert_called_once_with("test") + + def testReturnsTrue(self): + validator = LambdaValidator(lambda x: True) + self.assertTrue(validator.isValid("any value")) + self.assertTrue(validator.isValid(3.1415926)) + + def testReturnsFalse(self): + validator = LambdaValidator(lambda x: False) + self.assertFalse(validator.isValid("any value")) + self.assertFalse(validator.isValid(42)) diff --git a/openwebrx/test/property/validators/test_number_validator.py b/openwebrx/test/property/validators/test_number_validator.py new file mode 100644 index 0000000..3eff11b --- /dev/null +++ b/openwebrx/test/property/validators/test_number_validator.py @@ -0,0 +1,18 @@ +from unittest import TestCase +from owrx.property.validators import NumberValidator + + +class NumberValidatorTest(TestCase): + def testPassesNumbers(self): + validator = NumberValidator() + self.assertTrue(validator.isValid(123)) + self.assertTrue(validator.isValid(-2)) + self.assertTrue(validator.isValid(.5)) + + def testDoesntPassOthers(self): + validator = NumberValidator() + # bool is a subclass of int, so it passes this test. + # not sure if we need to be more specific or if this is alright. + # self.assertFalse(validator.isValid(True)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/openwebrx/test/property/validators/test_or_validator.py b/openwebrx/test/property/validators/test_or_validator.py new file mode 100644 index 0000000..0f7f79d --- /dev/null +++ b/openwebrx/test/property/validators/test_or_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import OrValidator, IntegerValidator, StringValidator + + +class OrValidatorTest(TestCase): + def testPassesAnyValidators(self): + validator = OrValidator(IntegerValidator(), StringValidator()) + self.assertTrue(validator.isValid(42)) + self.assertTrue(validator.isValid("text")) + + def testRejectsOtherTypes(self): + validator = OrValidator(IntegerValidator(), StringValidator()) + self.assertFalse(validator.isValid(.5)) + + def testRejectsIfNoValidator(self): + validator = OrValidator() + self.assertFalse(validator.isValid("any value")) diff --git a/openwebrx/test/property/validators/test_regex_validator.py b/openwebrx/test/property/validators/test_regex_validator.py new file mode 100644 index 0000000..2151d1b --- /dev/null +++ b/openwebrx/test/property/validators/test_regex_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import RegexValidator +import re + + +class RegexValidatorTest(TestCase): + def testMatchesRegex(self): + validator = RegexValidator(re.compile("abc")) + self.assertTrue(validator.isValid("abc")) + + def testDoesntMatchRegex(self): + validator = RegexValidator(re.compile("abc")) + self.assertFalse(validator.isValid("xyz")) + + def testFailsIfValueIsNoString(self): + validator = RegexValidator(re.compile("abc")) + self.assertFalse(validator.isValid(42)) diff --git a/openwebrx/test/property/validators/test_string_validator.py b/openwebrx/test/property/validators/test_string_validator.py new file mode 100644 index 0000000..d285f1a --- /dev/null +++ b/openwebrx/test/property/validators/test_string_validator.py @@ -0,0 +1,14 @@ +from unittest import TestCase +from owrx.property.validators import StringValidator + +class StringValidatorTest(TestCase): + def testPassesStrings(self): + validator = StringValidator() + self.assertTrue(validator.isValid("text")) + + def testDoesntPassOthers(self): + validator = StringValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid(object())) diff --git a/openwebrx/test/property/validators/test_validator.py b/openwebrx/test/property/validators/test_validator.py new file mode 100644 index 0000000..6fbbf78 --- /dev/null +++ b/openwebrx/test/property/validators/test_validator.py @@ -0,0 +1,20 @@ +from unittest import TestCase +from owrx.property.validators import Validator, NumberValidator, LambdaValidator, StringValidator + + +class ValidatorTest(TestCase): + + def testReturnsValidator(self): + validator = NumberValidator() + self.assertIs(validator, Validator.of(validator)) + + def testTransformsLambda(self): + def my_callable(v): + return True + validator = Validator.of(my_callable) + self.assertIsInstance(validator, LambdaValidator) + self.assertTrue(validator.isValid("test")) + + def testGetsValidatorByKey(self): + validator = Validator.of("str") + self.assertIsInstance(validator, StringValidator)