Compare commits
476 Commits
original_w
...
image-2019
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
774b71f8f0 | ||
|
|
5903ae1603 | ||
|
|
1c72e9ac50 | ||
|
|
b662c547f3 | ||
|
|
fac19e09cd | ||
|
|
5a3e2a2575 | ||
|
|
eed520daac | ||
|
|
4a7b42202e | ||
|
|
f292ba55c1 | ||
|
|
fef6f3bbd1 | ||
|
|
af9fcbc38d | ||
|
|
bd9cdc1cba | ||
|
|
be21d4c9ac | ||
|
|
b29d3c575d | ||
|
|
39a4366eab | ||
|
|
4c2979d242 | ||
|
|
4407146962 | ||
|
|
c3bcb17312 | ||
|
|
1b95807ac6 | ||
|
|
240074bdc5 | ||
|
|
46162dadbe | ||
|
|
455001a759 | ||
|
|
31881ce472 | ||
|
|
9669b4e365 | ||
|
|
d0c0ee2981 | ||
|
|
12a341e607 | ||
|
|
020445743c | ||
|
|
cc98c94b2b | ||
|
|
00febdf255 | ||
|
|
cbc7b73b1d | ||
|
|
42c59a3aa0 | ||
|
|
5f703a043b | ||
|
|
76fe11741a | ||
|
|
2c4add6aad | ||
|
|
6cb7e65231 | ||
|
|
2d1bcf221c | ||
|
|
a761559fd3 | ||
|
|
b27eb4a173 | ||
|
|
01fabd0342 | ||
|
|
6911ca407e | ||
|
|
68fbc436f2 | ||
|
|
ecb754ab29 | ||
|
|
41bd018191 | ||
|
|
bfcbd0265a | ||
|
|
45479b9f65 | ||
|
|
a68ba01320 | ||
|
|
ba03243527 | ||
|
|
22f4504629 | ||
|
|
bf59ed34cf | ||
|
|
d8bc2cab2e | ||
|
|
f8dcff788b | ||
|
|
4be34e4dc1 | ||
|
|
b1742dafc2 | ||
|
|
e24de8334f | ||
|
|
ae87185ad0 | ||
|
|
72f92a1c2b | ||
|
|
8b9121a5c1 | ||
|
|
cfb4208db2 | ||
|
|
52afe3fb02 | ||
|
|
57975b6f96 | ||
|
|
b4ffc6e2f0 | ||
|
|
1ed69de5b0 | ||
|
|
6ec85aa349 | ||
|
|
671509df3b | ||
|
|
2edeffb761 | ||
|
|
428a9ca509 | ||
|
|
cf273021ab | ||
|
|
ecbae5af2d | ||
|
|
15c28b130d | ||
|
|
996422ff4b | ||
|
|
e231c07c80 | ||
|
|
3e8e0c9224 | ||
|
|
c6c4012a36 | ||
|
|
30512e347a | ||
|
|
6f983ccb6b | ||
|
|
3814767e28 | ||
|
|
243e73064a | ||
|
|
8df4f9ce52 | ||
|
|
b0b2df5422 | ||
|
|
5b6edd110d | ||
|
|
392c226cbe | ||
|
|
7689d1a2e2 | ||
|
|
711bd18d06 | ||
|
|
98f1545fca | ||
|
|
8d47259f78 | ||
|
|
311f22f6ba | ||
|
|
5bcad1ef2f | ||
|
|
be05b54053 | ||
|
|
6ff55e1279 | ||
|
|
338a19373c | ||
|
|
bc5b16b5e3 | ||
|
|
a11875145b | ||
|
|
25a1d06dcb | ||
|
|
d87e5da75c | ||
|
|
6d44aa3f58 | ||
|
|
08cf8977f7 | ||
|
|
942ee637b0 | ||
|
|
aac618bfee | ||
|
|
2dcdad3a49 | ||
|
|
db8d4cd3fe | ||
|
|
de22169ea8 | ||
|
|
b24e56803c | ||
|
|
5530c96f8e | ||
|
|
1d8fea891a | ||
|
|
707fcdb1ab | ||
|
|
1a2f6b4970 | ||
|
|
4409a369fa | ||
|
|
272c305ec2 | ||
|
|
a81c5f44a2 | ||
|
|
2a09462f6f | ||
|
|
fdd74e2e09 | ||
|
|
5cc67aba15 | ||
|
|
62e9a39557 | ||
|
|
fadcb9b43f | ||
|
|
24d134ad6c | ||
|
|
faaef9d9f8 | ||
|
|
c5cc364918 | ||
|
|
ed9057e780 | ||
|
|
9bdeda7814 | ||
|
|
e4ef364aa8 | ||
|
|
379251d29d | ||
|
|
cf8b84925e | ||
|
|
f07bc9e6de | ||
|
|
94533e277c | ||
|
|
73102053dc | ||
|
|
5fab3e3d36 | ||
|
|
54bcba195d | ||
|
|
7e757c005c | ||
|
|
82eaff5da6 | ||
|
|
1eb28d6aee | ||
|
|
bdbe45e322 | ||
|
|
34a8311647 | ||
|
|
cf45caa762 | ||
|
|
5b72728aa2 | ||
|
|
67f3dc7430 | ||
|
|
b40af9bbdc | ||
|
|
cc66ffd6f3 | ||
|
|
5a7ef65c56 | ||
|
|
46ac0ecc77 | ||
|
|
cc6561bdda | ||
|
|
3022406f63 | ||
|
|
66382eb50f | ||
|
|
21591ad6b8 | ||
|
|
88bbb76752 | ||
|
|
765f075576 | ||
|
|
6b93973d9b | ||
|
|
439da266a9 | ||
|
|
0207374592 | ||
|
|
7beb773a37 | ||
|
|
4b3a68f4cd | ||
|
|
3dbc6ffb2b | ||
|
|
bf5e2bcc84 | ||
|
|
b80e85638a | ||
|
|
12c92928fa | ||
|
|
e5dffc3d9f | ||
|
|
fe84a39097 | ||
|
|
55c8ce7cf0 | ||
|
|
cbb65e8d79 | ||
|
|
2053e5f521 | ||
|
|
f53b51a208 | ||
|
|
e63569e3e9 | ||
|
|
2fed83659f | ||
|
|
ef90e3e048 | ||
|
|
5fbbd897b5 | ||
|
|
b0056a4677 | ||
|
|
d467d79bdf | ||
|
|
92321a3b4e | ||
|
|
766300bdff | ||
|
|
8214fdb24d | ||
|
|
42aae4c03a | ||
|
|
441738e569 | ||
|
|
5337ddba8d | ||
|
|
d1eaab7711 | ||
|
|
8f7f34c190 | ||
|
|
e40b400f6f | ||
|
|
3b5883dd55 | ||
|
|
785d439605 | ||
|
|
ff98b172c4 | ||
|
|
30d8b1327b | ||
|
|
74dddcb8ad | ||
|
|
6e7d99376d | ||
|
|
98c5e9e15b | ||
|
|
fa08009c50 | ||
|
|
ce662796e3 | ||
|
|
accf2a34ff | ||
|
|
a15e625692 | ||
|
|
7689e31640 | ||
|
|
8c2cefe304 | ||
|
|
eb9bc5f8dc | ||
|
|
9c927d9001 | ||
|
|
2d6b0f1877 | ||
|
|
6c2488f052 | ||
|
|
479c49b02e | ||
|
|
c0a0a642f9 | ||
|
|
35f8daee29 | ||
|
|
e15dc1ce11 | ||
|
|
79062ff3d6 | ||
|
|
fc5abd38cc | ||
|
|
6900810f5d | ||
|
|
2fae8ffa70 | ||
|
|
ea9feeefd2 | ||
|
|
f09f730bff | ||
|
|
25b0e86f09 | ||
|
|
18b65f769f | ||
|
|
abd5cf0795 | ||
|
|
6e08a428d6 | ||
|
|
a1856482ff | ||
|
|
a7a032dc8f | ||
|
|
4493f369dd | ||
|
|
f1098801e2 | ||
|
|
a15341fdcf | ||
|
|
c94331bf24 | ||
|
|
7dcfead843 | ||
|
|
0bb8b5349d | ||
|
|
30b46c4cdd | ||
|
|
69c3a63794 | ||
|
|
dd1def149c | ||
|
|
a6f294f361 | ||
|
|
6d5c8491e4 | ||
|
|
420b0c60d7 | ||
|
|
9f2b715d9f | ||
|
|
f490fbc2c9 | ||
|
|
95c117973f | ||
|
|
9a25c68d9a | ||
|
|
935e79c9c2 | ||
|
|
efc5b936f8 | ||
|
|
c19337d65c | ||
|
|
2470c2bfa6 | ||
|
|
acbf2939c9 | ||
|
|
8edc7c1374 | ||
|
|
d606c85443 | ||
|
|
5ada234f64 | ||
|
|
fdd2dd1b40 | ||
|
|
d2f524bf90 | ||
|
|
5887522dce | ||
|
|
688bd769dd | ||
|
|
649450a24c | ||
|
|
2bf2fcd685 | ||
|
|
d57f9de21e | ||
|
|
596c868b9d | ||
|
|
8a8768ed1d | ||
|
|
32c76beaa2 | ||
|
|
cb0b950d34 | ||
|
|
2536d9f747 | ||
|
|
438efa655f | ||
|
|
ad9855a791 | ||
|
|
58e819606a | ||
|
|
bab8ec1eaa | ||
|
|
c6aa5c3a3c | ||
|
|
c7503f87d7 | ||
|
|
561ff95436 | ||
|
|
2201daaa20 | ||
|
|
94afa94428 | ||
|
|
83273636f6 | ||
|
|
30b56c553e | ||
|
|
8b5dc8b3ad | ||
|
|
d1f46c8f55 | ||
|
|
d0cecbdfd7 | ||
|
|
1a257064f7 | ||
|
|
182a8af57f | ||
|
|
af315e1671 | ||
|
|
ceea2475a1 | ||
|
|
c22d10d0de | ||
|
|
849337c55d | ||
|
|
25bc788595 | ||
|
|
48baea3304 | ||
|
|
a6d7209a45 | ||
|
|
eb1b1ba22f | ||
|
|
d8a7dfbdbd | ||
|
|
fa2d82ac13 | ||
|
|
284646ee6c | ||
|
|
3f05565b7b | ||
|
|
089964a5eb | ||
|
|
31b8dd4fd5 | ||
|
|
892c92eb1d | ||
|
|
d0d5dffe79 | ||
|
|
823a4a35f0 | ||
|
|
e61c0dcc12 | ||
|
|
f5f23e6fbc | ||
|
|
3b2b51f07c | ||
|
|
272caa7100 | ||
|
|
2324a2c837 | ||
|
|
893f69ad18 | ||
|
|
a4a306374d | ||
|
|
f283a1ad68 | ||
|
|
0e205ec1d9 | ||
|
|
c3411b8856 | ||
|
|
7e0591f0a6 | ||
|
|
1f6f755d7f | ||
|
|
08edcd44ef | ||
|
|
84ddcbb74d | ||
|
|
f16a5f92e6 | ||
|
|
a66b540254 | ||
|
|
a8b2e21a5a | ||
|
|
72bf698d95 | ||
|
|
7a54cf25d1 | ||
|
|
96468f9258 | ||
|
|
231e4e72d9 | ||
|
|
3b04465106 | ||
|
|
4e9ef89276 | ||
|
|
8af8f93434 | ||
|
|
7362e48cf3 | ||
|
|
efa0c060fe | ||
|
|
adf62bc2ca | ||
|
|
3a89f52028 | ||
|
|
c7d969c96e | ||
|
|
2053a6b16b | ||
|
|
e1d54bdf1d | ||
|
|
761ca1132d | ||
|
|
2010a38411 | ||
|
|
94516ef341 | ||
|
|
cde3ff703a | ||
|
|
b852fcc167 | ||
|
|
f9c14addcc | ||
|
|
a9d5fcf82a | ||
|
|
b6e59e9b11 | ||
|
|
e8a1a40dc0 | ||
|
|
4b2100b593 | ||
|
|
a38872b2d0 | ||
|
|
e422ca4d9b | ||
|
|
f49086a527 | ||
|
|
aa7212c642 | ||
|
|
0c59caa230 | ||
|
|
4934e91e74 | ||
|
|
546249e950 | ||
|
|
b7fc6a9c87 | ||
|
|
2121739925 | ||
|
|
908e3036e0 | ||
|
|
f565b4dbcd | ||
|
|
7100d43d9e | ||
|
|
14f932eea8 | ||
|
|
05f6fff8f6 | ||
|
|
725615fbe5 | ||
|
|
1846605184 | ||
|
|
224c895718 | ||
|
|
8a7aeca6b9 | ||
|
|
7893216cce | ||
|
|
a36eb55680 | ||
|
|
8091831b1f | ||
|
|
3a669294d7 | ||
|
|
e79c830db5 | ||
|
|
92abef7172 | ||
|
|
eb758685a1 | ||
|
|
bb6b00a998 | ||
|
|
edadc383ff | ||
|
|
0629e6c777 | ||
|
|
e6150e4aca | ||
|
|
ff8f03c983 | ||
|
|
0ab14f63cb | ||
|
|
8e195a0de9 | ||
|
|
7d4111fec8 | ||
|
|
bd27d91529 | ||
|
|
9e0c2580d2 | ||
|
|
35757168d4 | ||
|
|
3f7ba343a2 | ||
|
|
a6c845de16 | ||
|
|
b1596cbb60 | ||
|
|
4496fcc8b0 | ||
|
|
cffb65e37d | ||
|
|
117d0483f7 | ||
|
|
03049b79dd | ||
|
|
5e67f036b4 | ||
|
|
9812d38eee | ||
|
|
5733a5be9f | ||
|
|
2ddfa4d4f6 | ||
|
|
2408d77f15 | ||
|
|
823995d4ba | ||
|
|
a85a6c694c | ||
|
|
17a362fe7a | ||
|
|
85be2e97a1 | ||
|
|
ddf9123e8b | ||
|
|
da37d03104 | ||
|
|
210fe5352f | ||
|
|
697e177f00 | ||
|
|
dd6c7bb2ea | ||
|
|
3c5aa89469 | ||
|
|
fbe43a1715 | ||
|
|
b34c1138b9 | ||
|
|
de84dc71e8 | ||
|
|
d5f17d66d9 | ||
|
|
8617997e23 | ||
|
|
c7e4d6b976 | ||
|
|
b9d2654669 | ||
|
|
dc44c9ed61 | ||
|
|
1c4543b7bf | ||
|
|
dac35ae526 | ||
|
|
0a22978660 | ||
|
|
abb5b65217 | ||
|
|
b91d24f8d2 | ||
|
|
475631a06f | ||
|
|
981ca755c6 | ||
|
|
e15359a106 | ||
|
|
6243a297c0 | ||
|
|
859e3931c6 | ||
|
|
52098cf9f9 | ||
|
|
1108cd9a96 | ||
|
|
dd3a970497 | ||
|
|
b17364e701 | ||
|
|
7427fa3608 | ||
|
|
1cf4a879f7 | ||
|
|
08e0a0af19 | ||
|
|
b3d5f924c3 | ||
|
|
bbd6412e3d | ||
|
|
56ef86aab6 | ||
|
|
bd627d77b7 | ||
|
|
6eb37b989f | ||
|
|
7550a6294e | ||
|
|
80d387743a | ||
|
|
56dcd00e82 | ||
|
|
425517d576 | ||
|
|
bd7cd01359 | ||
|
|
f5d9306c37 | ||
|
|
4cd23cf445 | ||
|
|
35930f79f1 | ||
|
|
9fc77c2804 | ||
|
|
fa05249a9d | ||
|
|
7eaada4726 | ||
|
|
cb187fd3c2 | ||
|
|
df9646aaf9 | ||
|
|
e937f2bca3 | ||
|
|
efb6e9c6cd | ||
|
|
f44ff3715f | ||
|
|
7732b3f685 | ||
|
|
1c2810ccb8 | ||
|
|
a4313c3340 | ||
|
|
628731cba4 | ||
|
|
30f8244abf | ||
|
|
142a4c87bd | ||
|
|
cb0d59de61 | ||
|
|
0da62dad82 | ||
|
|
854ac6d5f1 | ||
|
|
0f86796e75 | ||
|
|
7481399908 | ||
|
|
716542107f | ||
|
|
6c82c36915 | ||
|
|
f05afc4b0a | ||
|
|
6ec21e6716 | ||
|
|
1f909080db | ||
|
|
89690d214d | ||
|
|
bd8e665198 | ||
|
|
6294797466 | ||
|
|
7bec9eaa87 | ||
|
|
8c0a818549 | ||
|
|
d5b5fc3798 | ||
|
|
a2766bcc2e | ||
|
|
9953c7d1e1 | ||
|
|
8d10fc573f | ||
|
|
dea09d8eaa | ||
|
|
74930ba253 | ||
|
|
28f84c5188 | ||
|
|
b2b04dc65f | ||
|
|
a712d5ca3e | ||
|
|
a60521420b | ||
|
|
896fd0c178 | ||
|
|
075fee46b7 | ||
|
|
4f6a9249e8 | ||
|
|
51b9d1289a | ||
|
|
27571bd63a | ||
|
|
3e2c20b204 | ||
|
|
8ab42ce944 | ||
|
|
d1ce737886 | ||
|
|
7e08c8f28e | ||
|
|
aa03def329 | ||
|
|
7f90c0a67a | ||
|
|
bf4c70dfef | ||
|
|
ac6e001fd6 | ||
|
|
89740b1a93 | ||
|
|
aa959cdc93 | ||
|
|
6d5a7ffefc | ||
|
|
95acf40eb6 | ||
|
|
7700214e5f | ||
|
|
49e3bd3b80 | ||
|
|
fba07c521a | ||
|
|
9569fbd72e | ||
|
|
0a389256eb | ||
|
|
ddac30db63 |
143
README.md
@@ -1,64 +1,72 @@
|
|||||||
# OpenWebRX
|
OpenWebRX
|
||||||
|
=========
|
||||||
OpenWebRX is a multi-user SDR receiver software with a web interface.
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
### ⚠️ From 2019-12-29 OpenWebRX development is discontinued. ⚠️
|
|
||||||
|
|
||||||
I'm would like to say a big thanks to everyone who supported me during this project, including those who contributed either code or donations. It has been a very fruitful 6 years, but now it's time to move on to other projects. See also my [blog](https://blog.sdr.hu) about that.
|
|
||||||
|
|
||||||
(@simonyiszk, please keep this GitHub repo for historic purposes.)
|
|
||||||
|
|
||||||
Know limitations of the last version:
|
|
||||||
|
|
||||||
- Python 2.7, a main dependency of the project, will be not be officially maintained from 1 January 2020. By time, probably it will not be secure to use this version on public servers, unless someone still provides security patches for Python 2.
|
|
||||||
- Some specific parts of the DSP code could be improved for better SNR.
|
|
||||||
|
|
||||||
Even though these limitations are probably acceptable in an amateur radio project, I would not build critical infrastructure on it.
|
|
||||||
|
|
||||||
For commercial inquiries (e.g. if someone wants me to develop an improved version without these limitations), I'm still open, [drop me an e-mail](mailto:randras@sdr.hu).
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
[:floppy_disk: Setup guide for Ubuntu](http://blog.sdr.hu/2015/06/30/quick-setup-openwebrx.html) | [:blue_book: Knowledge base on the Wiki](https://github.com/simonyiszk/openwebrx/wiki/) | [:earth_americas: Receivers on SDR.hu](http://sdr.hu/)
|
[:floppy_disk: Setup guide for Ubuntu](http://blog.sdr.hu/2015/06/30/quick-setup-openwebrx.html) | [:blue_book: Knowledge base on the Wiki](https://github.com/simonyiszk/openwebrx/wiki/) | [:earth_americas: Receivers on SDR.hu](http://sdr.hu/)
|
||||||
|
|
||||||
|
OpenWebRX is a multi-user SDR receiver software with a web interface.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
It has the following features:
|
It has the following features:
|
||||||
|
|
||||||
- <a href="https://github.com/simonyiszk/csdr">csdr</a> based demodulators (AM/FM/SSB/CW/BPSK31),
|
- [csdr](https://github.com/simonyiszk/csdr) based demodulators (AM/FM/SSB/CW/BPSK31),
|
||||||
- filter passband can be set from GUI,
|
- filter passband can be set from GUI,
|
||||||
- waterfall display can be shifted back in time,
|
- it extensively uses HTML5 features like WebSocket, Web Audio API, and Canvas
|
||||||
- it extensively uses HTML5 features like WebSocket, Web Audio API, and <canvas>,
|
- it works in Google Chrome, Chromium and Mozilla Firefox
|
||||||
- it works in Google Chrome, Chromium (above version 37) and Mozilla Firefox (above version 28),
|
- currently supports RTL-SDR, HackRF, SDRplay, AirSpy
|
||||||
- currently supports RTL-SDR, HackRF, SDRplay, AirSpy and many other devices, see the <a href="https://github.com/simonyiszk/openwebrx/wiki/">OpenWebRX Wiki</a>,
|
- Multiple SDR devices can be used simultaneously
|
||||||
- it has a 3D waterfall display:
|
- [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF)
|
||||||
|
- [dsd](https://github.com/f4exb/dsdcc) based demodulators (D-Star, NXDN)
|
||||||
|
- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) based demodulators (FT8, FT4, WSPR, JT65, JT9)
|
||||||
|
|
||||||

|
**News (2019-09-29 by DD5FJK)**
|
||||||
|
- One of the most-requested features is finally coming to OpenWebRX: Bookmarks (sometimes also referred to as labels). There's two kinds of bookmarks available:
|
||||||
|
- Serverside bookmarks that are set up by the receiver administrator. Check the file `bookmarks.json` for examples!
|
||||||
|
- Clientside bookmarks which every user can store for themselves. They are stored in the browser's localStorage.
|
||||||
|
- Some more bugs in the websocket handling have been fixed.
|
||||||
|
|
||||||
**News (2015-08-18)**
|
**News (2019-09-25 by DD5JFK)**
|
||||||
- My BSc. thesis written on OpenWebRX is <a href="https://sdr.hu/static/bsc-thesis.pdf">available here.</a>
|
- 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.
|
||||||
- Several bugs were fixed to improve reliability and stability.
|
- 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.
|
||||||
- OpenWebRX now supports compression of audio and waterfall stream, so the required network uplink bandwidth has been decreased from 2 Mbit/s to about 200 kbit/s per client! (Measured with the default settings. It is also dependent on `fft_size`.)
|
- Profile scheduling allows to set up band-hopping if you are running background services.
|
||||||
- OpenWebRX now uses <a href="https://github.com/simonyiszk/csdr#sdrjs">sdr.js</a> (*libcsdr* compiled to JavaScript) for some client-side DSP tasks.
|
- APRS now has the ability to show symbols on the map, if a corresponding symbol set has been installed. Check the config!
|
||||||
- Receivers can now be listed on <a href="http://sdr.hu/">SDR.hu</a>.
|
- Debug logging has been disabled in a handful of modules, expect vastly reduced output on the shell.
|
||||||
- License for OpenWebRX is now Affero GPL v3.
|
|
||||||
|
|
||||||
**News (2016-02-14)**
|
**News (2019-09-13 by DD5JFK)**
|
||||||
- The DDC in *csdr* has been manually optimized for ARM NEON, so it runs around 3 times faster on the Raspberry Pi 2 than before.
|
- New set of APRS-related features
|
||||||
- Also we use *ncat* instead of *rtl_mus*, and it is 3 times faster in some cases.
|
- Decode Packet transmissions using [direwolf](https://github.com/wb2osz/direwolf) (1k2 only for now)
|
||||||
- OpenWebRX now supports URLs like: `http://localhost:8073/#freq=145555000,mod=usb`
|
- APRS packets are mostly decoded and shown both in a new panel and on the map
|
||||||
- UI improvements were made, thanks to John Seamons and Gnoxter.
|
- APRS is also available as a background service
|
||||||
|
- direwolfs I-gate functionality can be enabled, which allows your receiver to work as a receive-only I-gate for the APRS network in the background
|
||||||
|
- Demodulation for background services has been optimized to use less total bandwidth, saving CPU
|
||||||
|
- More metrics have been added; they can be used together with collectd and its curl_json plugin for now, with some limitations.
|
||||||
|
|
||||||
**News (2017-04-04)**
|
**News (2019-07-21 by DD5JFK)**
|
||||||
- *ncat* has been replaced with a custom implementation called *nmux* due to a bug that caused regular crashes on some machines. The *nmux* tool is part of the *csdr* package.
|
- Latest Features:
|
||||||
- Most consumer SDR devices are supported via <a href="https://github.com/rxseger/rx_tools">rx_tools</a>, see the <a href="https://github.com/simonyiszk/openwebrx/wiki/Using-rx_tools-with-OpenWebRX">OpenWebRX Wiki</a> on that.
|
- More WSJT-X modes have been added, including the new FT4 mode
|
||||||
|
- I started adding a bandplan feature, the first thing visible is the "dial" indicator that brings you right to the dial frequency for digital modes
|
||||||
|
- fixed some bugs in the websocket communication which broke the map
|
||||||
|
|
||||||
**News (2017-07-12)**
|
**News (2019-07-13 by DD5JFK)**
|
||||||
- OpenWebRX now has a BPSK31 demodulator and a 3D waterfall display.
|
- 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)
|
||||||
|
|
||||||
> When upgrading OpenWebRX, please make sure that you also upgrade *csdr*!
|
**News (2019-06-30 by DD5JFK)**
|
||||||
|
- I have done some major rework on the openwebrx core, and I am planning to continue adding more features in the near future. Please check this place for updates.
|
||||||
|
- My work has not been accepted into the upstream repository, so you will need to chose between my fork and the official version.
|
||||||
|
- I have enabled the issue tracker on this project, so feel free to file bugs or suggest enhancements there!
|
||||||
|
- This version sports the following new and amazing features:
|
||||||
|
- Support of multiple SDR devices simultaneously
|
||||||
|
- Support for multiple profiles per SDR that allow the user to listen to different frequencies
|
||||||
|
- Support for digital voice decoding
|
||||||
|
- Feature detection that will disable functionality when dependencies are not available (if you're missing the digital buttons, this is probably why)
|
||||||
|
- Raspbian SD Card Images and Docker builds available (see below)
|
||||||
|
- I am currently working on the feature set for a stable release, but you are more than welcome to test development versions!
|
||||||
|
|
||||||
|
> When upgrading OpenWebRX, please make sure that you also upgrade *csdr* and *digiham*!
|
||||||
|
|
||||||
## OpenWebRX servers on SDR.hu
|
## OpenWebRX servers on SDR.hu
|
||||||
|
|
||||||
@@ -68,22 +76,49 @@ It has the following features:
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
OpenWebRX currently requires Linux and python 2.7 to run.
|
### Raspberry Pi SD Card Images
|
||||||
|
|
||||||
|
Probably the quickest way to get started is to download the [latest Raspberry Pi SD Card Image](https://s3.eu-central-1.amazonaws.com/de.dd5jfk.openwebrx/2019-09-29-OpenWebRX-full.zip). It contains all the depencencies out of the box, and should work on all Raspberries up to the 3B+.
|
||||||
|
|
||||||
|
This is based off the Raspbian Lite distribution, so [their installation instructions](https://www.raspberrypi.org/documentation/installation/installing-images/) apply.
|
||||||
|
|
||||||
|
Please note: I have not updated this to include the Raspberry Pi 4 yet. (It seems to be impossible to build Rasbpian Buster images on x86 hardware right now. Stay tuned!)
|
||||||
|
|
||||||
|
Once you have booted a Raspberry with the SD Card, it will appear in your network with the hostname "openwebrx", which should make it available as http://openwebrx:8073/ on most networks. This may vary depending on your specific setup.
|
||||||
|
|
||||||
|
For Digital voice, the minimum requirement right now seems to be a Rasbperry Pi 3B+. I would like to work on optimizing this for lower specs, but at this point I am not sure how much can be done.
|
||||||
|
|
||||||
|
### Docker Images
|
||||||
|
|
||||||
|
For those familiar with docker, I am providing [recent builds and Releases for both x86 and arm processors on the Docker hub](https://hub.docker.com/r/jketterl/openwebrx). You can find a short introduction there.
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
|
||||||
|
OpenWebRX currently requires Linux and python 3 to run.
|
||||||
|
|
||||||
First you will need to install the dependencies:
|
First you will need to install the dependencies:
|
||||||
|
|
||||||
- <a href="https://github.com/simonyiszk/csdr">libcsdr</a>
|
- [csdr](https://github.com/simonyiszk/csdr)
|
||||||
- <a href="http://sdr.osmocom.org/trac/wiki/rtl-sdr">rtl-sdr</a>
|
- [rtl-sdr](http://sdr.osmocom.org/trac/wiki/rtl-sdr)
|
||||||
|
|
||||||
|
Optional Dependencies if you want to be able to listen do digital voice:
|
||||||
|
|
||||||
|
- [digiham](https://github.com/jketterl/digiham)
|
||||||
|
- [dsd](https://github.com/f4exb/dsdcc)
|
||||||
|
|
||||||
|
Optional Dependency if you want to decode WSJT-X modes:
|
||||||
|
|
||||||
|
- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html)
|
||||||
|
|
||||||
After cloning this repository and connecting an RTL-SDR dongle to your computer, you can run the server:
|
After cloning this repository and connecting an RTL-SDR dongle to your computer, you can run the server:
|
||||||
|
|
||||||
python openwebrx.py
|
./openwebrx.py
|
||||||
|
|
||||||
You can now open the GUI at <a href="http://localhost:8073">http://localhost:8073</a>.
|
You can now open the GUI at <a href="http://localhost:8073">http://localhost:8073</a>.
|
||||||
|
|
||||||
Please note that the server is also listening on the following ports (on localhost only):
|
Please note that the server is also listening on the following ports (on localhost only):
|
||||||
|
|
||||||
- port 4951 for the multi-user I/Q server.
|
- ports 4950 to 4960 for the multi-user I/Q servers.
|
||||||
|
|
||||||
Now the next step is to customize the parameters of your server in `config_webrx.py`.
|
Now the next step is to customize the parameters of your server in `config_webrx.py`.
|
||||||
|
|
||||||
@@ -104,8 +139,6 @@ If you have any problems installing OpenWebRX, you should check out the <a href=
|
|||||||
|
|
||||||
Sometimes the actual error message is not at the end of the terminal output, you may have to look at the whole output to find it.
|
Sometimes the actual error message is not at the end of the terminal output, you may have to look at the whole output to find it.
|
||||||
|
|
||||||
If you want to run OpenWebRX on a remote server instead of *localhost*, do not forget to set *server_hostname* in `config_webrx.py`.
|
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
OpenWebRX is available under Affero GPL v3 license (<a href="https://tldrlegal.com/license/gnu-affero-general-public-license-v3-(agpl-3.0)">summary</a>).
|
OpenWebRX is available under Affero GPL v3 license (<a href="https://tldrlegal.com/license/gnu-affero-general-public-license-v3-(agpl-3.0)">summary</a>).
|
||||||
|
|||||||
190
bands.json
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "160m",
|
||||||
|
"lower_bound": 1810000,
|
||||||
|
"upper_bound": 2000000,
|
||||||
|
"frequencies": {
|
||||||
|
"psk31": 1838000,
|
||||||
|
"ft8": 1840000,
|
||||||
|
"wspr": 1836600,
|
||||||
|
"jt65": 1838000,
|
||||||
|
"jt9": 1839000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "80m",
|
||||||
|
"lower_bound": 3500000,
|
||||||
|
"upper_bound": 3800000,
|
||||||
|
"frequencies": {
|
||||||
|
"psk31": 3580000,
|
||||||
|
"ft8": 3573000,
|
||||||
|
"wspr": 3592600,
|
||||||
|
"jt65": 3570000,
|
||||||
|
"jt9": 3572000,
|
||||||
|
"ft4": [3568000, 3575000]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "60m",
|
||||||
|
"lower_bound": 5351500,
|
||||||
|
"upper_bound": 5366500,
|
||||||
|
"frequencies": {
|
||||||
|
"ft8": 5357000,
|
||||||
|
"wspr": 5364700
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "40m",
|
||||||
|
"lower_bound": 7000000,
|
||||||
|
"upper_bound": 7200000,
|
||||||
|
"frequencies": {
|
||||||
|
"psk31": 7040000,
|
||||||
|
"ft8": 7074000,
|
||||||
|
"wspr": 7038600,
|
||||||
|
"jt65": 7076000,
|
||||||
|
"jt9": 7078000,
|
||||||
|
"ft4": 7047500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "30m",
|
||||||
|
"lower_bound": 10100000,
|
||||||
|
"upper_bound": 10150000,
|
||||||
|
"frequencies": {
|
||||||
|
"psk31": 10141000,
|
||||||
|
"ft8": 10136000,
|
||||||
|
"wspr": 10138700,
|
||||||
|
"jt65": 10138000,
|
||||||
|
"jt9": 10140000,
|
||||||
|
"ft4": 10140000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "20m",
|
||||||
|
"lower_bound": 14000000,
|
||||||
|
"upper_bound": 14350000,
|
||||||
|
"frequencies": {
|
||||||
|
"psk31": 14070000,
|
||||||
|
"ft8": 14074000,
|
||||||
|
"wspr": 14095600,
|
||||||
|
"jt65": 14076000,
|
||||||
|
"jt9": 14078000,
|
||||||
|
"ft4": 14080000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "17m",
|
||||||
|
"lower_bound": 18068000,
|
||||||
|
"upper_bound": 18168000,
|
||||||
|
"frequencies": {
|
||||||
|
"psk31": 18098000,
|
||||||
|
"ft8": 18100000,
|
||||||
|
"wspr": 18104600,
|
||||||
|
"jt65": 18102000,
|
||||||
|
"jt9": 18104000,
|
||||||
|
"ft4": 18104000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "15m",
|
||||||
|
"lower_bound": 21000000,
|
||||||
|
"upper_bound": 21450000,
|
||||||
|
"frequencies": {
|
||||||
|
"psk31": 21070000,
|
||||||
|
"ft8": 21074000,
|
||||||
|
"wspr": 21094600,
|
||||||
|
"jt65": 21076000,
|
||||||
|
"jt9": 21078000,
|
||||||
|
"ft4": 21140000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "12m",
|
||||||
|
"lower_bound": 24890000,
|
||||||
|
"upper_bound": 24990000,
|
||||||
|
"frequencies": {
|
||||||
|
"psk31": 24920000,
|
||||||
|
"ft8": 24915000,
|
||||||
|
"wspr": 24924600,
|
||||||
|
"jt65": 24917000,
|
||||||
|
"jt9": 24919000,
|
||||||
|
"ft4": 24919000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "10m",
|
||||||
|
"lower_bound": 28000000,
|
||||||
|
"upper_bound": 29700000,
|
||||||
|
"frequencies": {
|
||||||
|
"psk31": [28070000, 28120000],
|
||||||
|
"ft8": 28074000,
|
||||||
|
"wspr": 28124600,
|
||||||
|
"jt65": 28076000,
|
||||||
|
"jt9": 28078000,
|
||||||
|
"ft4": 28180000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "6m",
|
||||||
|
"lower_bound": 50030000,
|
||||||
|
"upper_bound": 51000000,
|
||||||
|
"frequencies": {
|
||||||
|
"psk31": 50305000,
|
||||||
|
"ft8": 50313000,
|
||||||
|
"wspr": 50293000,
|
||||||
|
"jt65": 50310000,
|
||||||
|
"jt9": 50312000,
|
||||||
|
"ft4": 50318000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "4m",
|
||||||
|
"lower_bound": 70150000,
|
||||||
|
"upper_bound": 70200000,
|
||||||
|
"frequencies": {
|
||||||
|
"wspr": 70091000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "2m",
|
||||||
|
"lower_bound": 144000000,
|
||||||
|
"upper_bound": 146000000,
|
||||||
|
"frequencies": {
|
||||||
|
"wspr": 144489000,
|
||||||
|
"ft8": 144174000,
|
||||||
|
"ft4": 144170000,
|
||||||
|
"jt65": 144120000,
|
||||||
|
"packet": 144800000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "70cm",
|
||||||
|
"lower_bound": 430000000,
|
||||||
|
"upper_bound": 440000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "23cm",
|
||||||
|
"lower_bound": 1240000000,
|
||||||
|
"upper_bound": 1300000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "13cm",
|
||||||
|
"lower_bound": 2320000000,
|
||||||
|
"upper_bound": 2450000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "9cm",
|
||||||
|
"lower_bound": 3400000000,
|
||||||
|
"upper_bound": 3475000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "6cm",
|
||||||
|
"lower_bound": 5650000000,
|
||||||
|
"upper_bound": 5850000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3cm",
|
||||||
|
"lower_bound": 10000000000,
|
||||||
|
"upper_bound": 10500000000
|
||||||
|
}
|
||||||
|
]
|
||||||
65
bookmarks.json
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
[{
|
||||||
|
"name": "DB0ZU",
|
||||||
|
"frequency": 145725000,
|
||||||
|
"modulation": "nfm"
|
||||||
|
},{
|
||||||
|
"name": "DB0ZM",
|
||||||
|
"frequency": 145750000,
|
||||||
|
"modulation": "nfm"
|
||||||
|
},{
|
||||||
|
"name": "DM0ULR",
|
||||||
|
"frequency": 145787500,
|
||||||
|
"modulation": "nfm"
|
||||||
|
},{
|
||||||
|
"name": "DB0EL",
|
||||||
|
"frequency": 439275000,
|
||||||
|
"modulation": "nfm"
|
||||||
|
},{
|
||||||
|
"name": "DB0NJ",
|
||||||
|
"frequency": 438775000,
|
||||||
|
"modulation": "nfm"
|
||||||
|
},{
|
||||||
|
"name": "DB0NJ",
|
||||||
|
"frequency": 439437500,
|
||||||
|
"modulation": "dmr"
|
||||||
|
},{
|
||||||
|
"name": "DB0UFO",
|
||||||
|
"frequency": 438312500,
|
||||||
|
"modulation": "dmr"
|
||||||
|
},{
|
||||||
|
"name": "DB0PV",
|
||||||
|
"frequency": 438525000,
|
||||||
|
"modulation": "ysf"
|
||||||
|
},{
|
||||||
|
"name": "DB0BZA",
|
||||||
|
"frequency": 438412500,
|
||||||
|
"modulation": "ysf"
|
||||||
|
},{
|
||||||
|
"name": "DB0OSH",
|
||||||
|
"frequency": 438250000,
|
||||||
|
"modulation": "ysf"
|
||||||
|
},{
|
||||||
|
"name": "DB0ULR",
|
||||||
|
"frequency": 439325000,
|
||||||
|
"modulation": "nfm"
|
||||||
|
},{
|
||||||
|
"name": "DB0ZU",
|
||||||
|
"frequency": 438850000,
|
||||||
|
"modulation": "nfm"
|
||||||
|
},{
|
||||||
|
"name": "DB0ISW",
|
||||||
|
"frequency": 438650000,
|
||||||
|
"modulation": "nfm"
|
||||||
|
},{
|
||||||
|
"name": "Radio DARC",
|
||||||
|
"frequency": 6070000,
|
||||||
|
"modulation": "am"
|
||||||
|
},{
|
||||||
|
"name": "DB0TVM",
|
||||||
|
"frequency": 439575000,
|
||||||
|
"modulation": "dstar"
|
||||||
|
},{
|
||||||
|
"name": "DB0TVM",
|
||||||
|
"frequency": 439800000,
|
||||||
|
"modulation": "dmr"
|
||||||
|
}]
|
||||||
22
build.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
|
||||||
|
case $ARCH in
|
||||||
|
x86_64)
|
||||||
|
BASE_IMAGE=alpine
|
||||||
|
;;
|
||||||
|
armv*)
|
||||||
|
BASE_IMAGE=arm32v6/alpine
|
||||||
|
esac
|
||||||
|
|
||||||
|
TAGS=$ARCH
|
||||||
|
|
||||||
|
docker build --build-arg BASE_IMAGE=$BASE_IMAGE -t openwebrx-base:$ARCH -f docker/Dockerfiles/Dockerfile-base .
|
||||||
|
docker build --build-arg ARCH=$ARCH -t jketterl/openwebrx-rtlsdr:$ARCH -f docker/Dockerfiles/Dockerfile-rtlsdr .
|
||||||
|
docker build --build-arg ARCH=$ARCH -t openwebrx-soapysdr-base:$ARCH -f docker/Dockerfiles/Dockerfile-soapysdr .
|
||||||
|
docker build --build-arg ARCH=$ARCH -t jketterl/openwebrx-sdrplay:$ARCH -f docker/Dockerfiles/Dockerfile-sdrplay .
|
||||||
|
docker build --build-arg ARCH=$ARCH -t jketterl/openwebrx-hackrf:$ARCH -f docker/Dockerfiles/Dockerfile-hackrf .
|
||||||
|
docker build --build-arg ARCH=$ARCH -t jketterl/openwebrx-airspy:$ARCH -f docker/Dockerfiles/Dockerfile-airspy .
|
||||||
|
docker build --build-arg ARCH=$ARCH -t jketterl/openwebrx-full:$ARCH -t jketterl/openwebrx:$ARCH -f docker/Dockerfiles/Dockerfile-full .
|
||||||
294
config_webrx.py
@@ -35,22 +35,21 @@ config_webrx: configuration options for OpenWebRX
|
|||||||
# https://github.com/simonyiszk/openwebrx/wiki
|
# https://github.com/simonyiszk/openwebrx/wiki
|
||||||
|
|
||||||
# ==== Server settings ====
|
# ==== Server settings ====
|
||||||
web_port=8073
|
web_port = 8073
|
||||||
server_hostname="localhost" # If this contains an incorrect value, the web UI may freeze on load (it can't open websocket)
|
max_clients = 20
|
||||||
max_clients=20
|
|
||||||
|
|
||||||
# ==== Web GUI configuration ====
|
# ==== Web GUI configuration ====
|
||||||
receiver_name="[Callsign]"
|
receiver_name = "[Callsign]"
|
||||||
receiver_location="Budapest, Hungary"
|
receiver_location = "Budapest, Hungary"
|
||||||
receiver_qra="JN97ML"
|
receiver_qra = "JN97ML"
|
||||||
receiver_asl=200
|
receiver_asl = 200
|
||||||
receiver_ant="Longwire"
|
receiver_ant = "Longwire"
|
||||||
receiver_device="RTL-SDR"
|
receiver_device = "RTL-SDR"
|
||||||
receiver_admin="example@example.com"
|
receiver_admin = "example@example.com"
|
||||||
receiver_gps=(47.000000,19.000000)
|
receiver_gps = (47.000000, 19.000000)
|
||||||
photo_height=350
|
photo_height = 350
|
||||||
photo_title="Panorama of Budapest from Schönherz Zoltán Dormitory"
|
photo_title = "Panorama of Budapest from Schönherz Zoltán Dormitory"
|
||||||
photo_desc="""
|
photo_desc = """
|
||||||
You can add your own background photo and receiver information.<br />
|
You can add your own background photo and receiver information.<br />
|
||||||
Receiver is operated by: <a href="mailto:%[RX_ADMIN]">%[RX_ADMIN]</a><br/>
|
Receiver is operated by: <a href="mailto:%[RX_ADMIN]">%[RX_ADMIN]</a><br/>
|
||||||
Device: %[RX_DEVICE]<br />
|
Device: %[RX_DEVICE]<br />
|
||||||
@@ -65,25 +64,26 @@ Website: <a href="http://localhost" target="_blank">http://localhost</a>
|
|||||||
sdrhu_key = ""
|
sdrhu_key = ""
|
||||||
# 3. Set this setting to True to enable listing:
|
# 3. Set this setting to True to enable listing:
|
||||||
sdrhu_public_listing = False
|
sdrhu_public_listing = False
|
||||||
|
server_hostname = "localhost"
|
||||||
|
|
||||||
# ==== DSP/RX settings ====
|
# ==== DSP/RX settings ====
|
||||||
fft_fps=9
|
fft_fps = 9
|
||||||
fft_size=4096 #Should be power of 2
|
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.
|
fft_voverlap_factor = (
|
||||||
|
0.3
|
||||||
|
) # If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram.
|
||||||
|
|
||||||
# samp_rate = 250000
|
audio_compression = "adpcm" # valid values: "adpcm", "none"
|
||||||
samp_rate = 2400000
|
fft_compression = "adpcm" # valid values: "adpcm", "none"
|
||||||
center_freq = 144250000
|
|
||||||
rf_gain = 5 #in dB. For an RTL-SDR, rf_gain=0 will set the tuner to auto gain mode, else it will be in manual gain mode.
|
|
||||||
ppm = 0
|
|
||||||
|
|
||||||
audio_compression="adpcm" #valid values: "adpcm", "none"
|
digimodes_enable = True # Decoding digimodes come with higher CPU usage.
|
||||||
fft_compression="adpcm" #valid values: "adpcm", "none"
|
digimodes_fft_size = 1024
|
||||||
|
|
||||||
digimodes_enable=True #Decoding digimodes come with higher CPU usage.
|
# determines the quality, and thus the cpu usage, for the ambe codec used by digital voice modes
|
||||||
digimodes_fft_size=1024
|
# if you're running on a Raspi (up to 3B+) you'll want to leave this on 1
|
||||||
|
digital_voice_unvoiced_quality = 1
|
||||||
start_rtl_thread=True
|
# 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:
|
Note: if you experience audio underruns while CPU usage is 100%, you can:
|
||||||
@@ -101,92 +101,119 @@ Note: if you experience audio underruns while CPU usage is 100%, you can:
|
|||||||
# Check here: https://github.com/simonyiszk/openwebrx/wiki#guides-for-receiver-hardware-support #
|
# Check here: https://github.com/simonyiszk/openwebrx/wiki#guides-for-receiver-hardware-support #
|
||||||
#################################################################################################
|
#################################################################################################
|
||||||
|
|
||||||
# You can use other SDR hardware as well, by giving your own command that outputs the I/Q samples... Some examples of configuration are available here (default is RTL-SDR):
|
# Currently supported types of sdr receivers: "rtl_sdr", "sdrplay", "hackrf", "airspy"
|
||||||
|
|
||||||
# >> RTL-SDR via rtl_sdr
|
sdrs = {
|
||||||
start_rtl_command="rtl_sdr -s {samp_rate} -f {center_freq} -p {ppm} -g {rf_gain} -".format(rf_gain=rf_gain, center_freq=center_freq, samp_rate=samp_rate, ppm=ppm)
|
"rtlsdr": {
|
||||||
format_conversion="csdr convert_u8_f"
|
"name": "RTL-SDR USB Stick",
|
||||||
|
"type": "rtl_sdr",
|
||||||
#lna_gain=8
|
"ppm": 0,
|
||||||
#rf_amp=1
|
# you can change this if you use an upconverter. formula is:
|
||||||
#start_rtl_command="hackrf_transfer -s {samp_rate} -f {center_freq} -g {rf_gain} -l{lna_gain} -a{rf_amp} -r-".format(rf_gain=rf_gain, center_freq=center_freq, samp_rate=samp_rate, ppm=ppm, rf_amp=rf_amp, lna_gain=lna_gain)
|
# shown_center_freq = center_freq + lfo_offset
|
||||||
#format_conversion="csdr convert_s8_f"
|
# "lfo_offset": 0,
|
||||||
"""
|
"profiles": {
|
||||||
To use a HackRF, compile the HackRF host tools from its "stdout" branch:
|
"70cm": {
|
||||||
git clone https://github.com/mossmann/hackrf/
|
"name": "70cm Relais",
|
||||||
cd hackrf
|
"center_freq": 438800000,
|
||||||
git fetch
|
"rf_gain": 30,
|
||||||
git checkout origin/stdout
|
"samp_rate": 2400000,
|
||||||
cd host
|
"start_freq": 439275000,
|
||||||
mkdir build
|
"start_mod": "nfm",
|
||||||
cd build
|
},
|
||||||
cmake .. -DINSTALL_UDEV_RULES=ON
|
"2m": {
|
||||||
make
|
"name": "2m komplett",
|
||||||
sudo make install
|
"center_freq": 145000000,
|
||||||
"""
|
"rf_gain": 30,
|
||||||
|
"samp_rate": 2400000,
|
||||||
# >> Sound card SDR (needs ALSA)
|
"start_freq": 145725000,
|
||||||
# I did not have the chance to properly test it.
|
"start_mod": "nfm",
|
||||||
#samp_rate = 96000
|
},
|
||||||
#start_rtl_command="arecord -f S16_LE -r {samp_rate} -c2 -".format(samp_rate=samp_rate)
|
},
|
||||||
#format_conversion="csdr convert_s16_f | csdr gain_ff 30"
|
},
|
||||||
|
"sdrplay": {
|
||||||
# >> /dev/urandom test signal source
|
"name": "SDRPlay RSP2",
|
||||||
# samp_rate = 2400000
|
"type": "sdrplay",
|
||||||
# start_rtl_command="cat /dev/urandom | (pv -qL `python -c 'print int({samp_rate} * 2.2)'` 2>&1)".format(rf_gain=rf_gain, center_freq=center_freq, samp_rate=samp_rate)
|
"ppm": 0,
|
||||||
# format_conversion="csdr convert_u8_f"
|
"profiles": {
|
||||||
|
"20m": {
|
||||||
# >> Pre-recorded raw I/Q file as signal source
|
"name": "20m",
|
||||||
# You will have to correctly specify: samp_rate, center_freq, format_conversion in order to correctly play an I/Q file.
|
"center_freq": 14150000,
|
||||||
#start_rtl_command="(while true; do cat my_iq_file.raw; done) | csdr flowcontrol {sr} 20 ".format(sr=samp_rate*2*1.05)
|
"rf_gain": 0,
|
||||||
#format_conversion="csdr convert_u8_f"
|
"samp_rate": 500000,
|
||||||
|
"start_freq": 14070000,
|
||||||
#>> The rx_sdr command works with a variety of SDR harware: RTL-SDR, HackRF, SDRplay, UHD, Airspy, Red Pitaya, audio devices, etc.
|
"start_mod": "usb",
|
||||||
# It will auto-detect your SDR hardware if the following tools are installed:
|
"antenna": "Antenna A",
|
||||||
# * the vendor provided driver and library,
|
},
|
||||||
# * the vendor-specific SoapySDR wrapper library,
|
"30m": {
|
||||||
# * and SoapySDR itself.
|
"name": "30m",
|
||||||
# Check out this article on the OpenWebRX Wiki: https://github.com/simonyiszk/openwebrx/wiki/Using-rx_tools-with-OpenWebRX/
|
"center_freq": 10125000,
|
||||||
#start_rtl_command="rx_sdr -F CF32 -s {samp_rate} -f {center_freq} -p {ppm} -g {rf_gain} -".format(rf_gain=rf_gain, center_freq=center_freq, samp_rate=samp_rate, ppm=ppm)
|
"rf_gain": 0,
|
||||||
#format_conversion=""
|
"samp_rate": 250000,
|
||||||
|
"start_freq": 10142000,
|
||||||
# >> gr-osmosdr signal source using GNU Radio (follow this guide: https://github.com/simonyiszk/openwebrx/wiki/Using-GrOsmoSDR-as-signal-source)
|
"start_mod": "usb",
|
||||||
#start_rtl_command="cat /tmp/osmocom_fifo"
|
},
|
||||||
#format_conversion=""
|
"40m": {
|
||||||
|
"name": "40m",
|
||||||
|
"center_freq": 7100000,
|
||||||
|
"rf_gain": 0,
|
||||||
|
"samp_rate": 500000,
|
||||||
|
"start_freq": 7070000,
|
||||||
|
"start_mod": "usb",
|
||||||
|
"antenna": "Antenna A",
|
||||||
|
},
|
||||||
|
"80m": {
|
||||||
|
"name": "80m",
|
||||||
|
"center_freq": 3650000,
|
||||||
|
"rf_gain": 0,
|
||||||
|
"samp_rate": 500000,
|
||||||
|
"start_freq": 3570000,
|
||||||
|
"start_mod": "usb",
|
||||||
|
"antenna": "Antenna A",
|
||||||
|
},
|
||||||
|
"49m": {
|
||||||
|
"name": "49m Broadcast",
|
||||||
|
"center_freq": 6000000,
|
||||||
|
"rf_gain": 0,
|
||||||
|
"samp_rate": 500000,
|
||||||
|
"start_freq": 6070000,
|
||||||
|
"start_mod": "am",
|
||||||
|
"antenna": "Antenna A",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# this one is just here to test feature detection
|
||||||
|
"test": {"type": "test"},
|
||||||
|
}
|
||||||
|
|
||||||
# ==== Misc settings ====
|
# ==== Misc settings ====
|
||||||
|
|
||||||
shown_center_freq = center_freq #you can change this if you use an upconverter
|
|
||||||
|
|
||||||
client_audio_buffer_size = 5
|
client_audio_buffer_size = 5
|
||||||
#increasing client_audio_buffer_size will:
|
# increasing client_audio_buffer_size will:
|
||||||
# - also increase the latency
|
# - also increase the latency
|
||||||
# - decrease the chance of audio underruns
|
# - decrease the chance of audio underruns
|
||||||
|
|
||||||
start_freq = center_freq
|
iq_port_range = [
|
||||||
start_mod = "nfm" #nfm, am, lsb, usb, cw
|
4950,
|
||||||
|
4960,
|
||||||
iq_server_port = 4951 #TCP port for ncat to listen on. It will send I/Q data over its connections, for internal use in OpenWebRX. It is only accessible from the localhost by default.
|
] # TCP port for range ncat to listen on. It will send I/Q data over its connections, for internal use in OpenWebRX. It is only accessible from the localhost by default.
|
||||||
|
|
||||||
#access_log = "~/openwebrx_access.log"
|
|
||||||
|
|
||||||
# ==== Color themes ====
|
# ==== Color themes ====
|
||||||
|
|
||||||
#A guide is available to help you set these values: https://github.com/simonyiszk/openwebrx/wiki/Calibrating-waterfall-display-levels
|
# A guide is available to help you set these values: https://github.com/simonyiszk/openwebrx/wiki/Calibrating-waterfall-display-levels
|
||||||
|
|
||||||
### default theme by teejez:
|
### default theme by teejez:
|
||||||
waterfall_colors = "[0x000000ff,0x0000ffff,0x00ffffff,0x00ff00ff,0xffff00ff,0xff0000ff,0xff00ffff,0xffffffff]"
|
waterfall_colors = [0x000000FF, 0x0000FFFF, 0x00FFFFFF, 0x00FF00FF, 0xFFFF00FF, 0xFF0000FF, 0xFF00FFFF, 0xFFFFFFFF]
|
||||||
waterfall_min_level = -88 #in dB
|
waterfall_min_level = -88 # in dB
|
||||||
waterfall_max_level = -20
|
waterfall_max_level = -20
|
||||||
waterfall_auto_level_margin = (5, 40)
|
waterfall_auto_level_margin = (5, 40)
|
||||||
### old theme by HA7ILM:
|
### old theme by HA7ILM:
|
||||||
#waterfall_colors = "[0x000000ff,0x2e6893ff, 0x69a5d0ff, 0x214b69ff, 0x9dc4e0ff, 0xfff775ff, 0xff8a8aff, 0xb20000ff]"
|
# waterfall_colors = "[0x000000ff,0x2e6893ff, 0x69a5d0ff, 0x214b69ff, 0x9dc4e0ff, 0xfff775ff, 0xff8a8aff, 0xb20000ff]"
|
||||||
#waterfall_min_level = -115 #in dB
|
# waterfall_min_level = -115 #in dB
|
||||||
#waterfall_max_level = 0
|
# waterfall_max_level = 0
|
||||||
#waterfall_auto_level_margin = (20, 30)
|
# waterfall_auto_level_margin = (20, 30)
|
||||||
##For the old colors, you might also want to set [fft_voverlap_factor] to 0.
|
##For the old colors, you might also want to set [fft_voverlap_factor] to 0.
|
||||||
|
|
||||||
#Note: When the auto waterfall level button is clicked, the following happens:
|
# Note: When the auto waterfall level button is clicked, the following happens:
|
||||||
# [waterfall_min_level] = [current_min_power_level] - [waterfall_auto_level_margin[0]]
|
# [waterfall_min_level] = [current_min_power_level] - [waterfall_auto_level_margin[0]]
|
||||||
# [waterfall_max_level] = [current_max_power_level] + [waterfall_auto_level_margin[1]]
|
# [waterfall_max_level] = [current_max_power_level] + [waterfall_auto_level_margin[1]]
|
||||||
#
|
#
|
||||||
@@ -195,22 +222,69 @@ waterfall_auto_level_margin = (5, 40)
|
|||||||
# current_max_power_level __|
|
# current_max_power_level __|
|
||||||
|
|
||||||
# 3D view settings
|
# 3D view settings
|
||||||
mathbox_waterfall_frequency_resolution = 128 #bins
|
mathbox_waterfall_frequency_resolution = 128 # bins
|
||||||
mathbox_waterfall_history_length = 10 #seconds
|
mathbox_waterfall_history_length = 10 # seconds
|
||||||
mathbox_waterfall_colors = "[0x000000ff,0x2e6893ff, 0x69a5d0ff, 0x214b69ff, 0x9dc4e0ff, 0xfff775ff, 0xff8a8aff, 0xb20000ff]"
|
mathbox_waterfall_colors = [
|
||||||
|
0x000000FF,
|
||||||
|
0x2E6893FF,
|
||||||
|
0x69A5D0FF,
|
||||||
|
0x214B69FF,
|
||||||
|
0x9DC4E0FF,
|
||||||
|
0xFFF775FF,
|
||||||
|
0xFF8A8AFF,
|
||||||
|
0xB20000FF,
|
||||||
|
]
|
||||||
|
|
||||||
# === Experimental settings ===
|
# === Experimental settings ===
|
||||||
#Warning! The settings below are very experimental.
|
# Warning! The settings below are very experimental.
|
||||||
csdr_dynamic_bufsize = False # This allows you to change the buffering mode of csdr.
|
csdr_dynamic_bufsize = False # This allows you to change the buffering mode of csdr.
|
||||||
csdr_print_bufsizes = False # This prints the buffer sizes used for csdr processes.
|
csdr_print_bufsizes = False # This prints the buffer sizes used for csdr processes.
|
||||||
csdr_through = False # Setting this True will print out how much data is going into the DSP chains.
|
csdr_through = False # Setting this True will print out how much data is going into the DSP chains.
|
||||||
|
|
||||||
nmux_memory = 50 #in megabytes. This sets the approximate size of the circular buffer used by nmux.
|
nmux_memory = 50 # in megabytes. This sets the approximate size of the circular buffer used by nmux.
|
||||||
|
|
||||||
#Look up external IP address automatically from icanhazip.com, and use it as [server_hostname]
|
google_maps_api_key = ""
|
||||||
"""
|
|
||||||
print "[openwebrx-config] Detecting external IP address..."
|
# how long should positions be visible on the map?
|
||||||
import urllib2
|
# they will start fading out after half of that
|
||||||
server_hostname=urllib2.urlopen("http://icanhazip.com").read()[:-1]
|
# in seconds; default: 2 hours
|
||||||
print "[openwebrx-config] External IP address detected:", server_hostname
|
map_position_retention_time = 2 * 60 * 60
|
||||||
"""
|
|
||||||
|
# wsjt decoder queue configuration
|
||||||
|
# due to the nature of the wsjt operating modes (ft8, ft8, jt9, jt65 and wspr), the data is recorded for a given amount
|
||||||
|
# of time (6.5 seconds up to 2 minutes) and decoded at the end. this can lead to very high peak loads.
|
||||||
|
# to mitigate this, the recordings will be queued and processed in sequence.
|
||||||
|
# the number of workers will limit the total amount of work (one worker will losely occupy one cpu / thread)
|
||||||
|
wsjt_queue_workers = 2
|
||||||
|
# the maximum queue length will cause decodes to be dumped if the workers cannot keep up
|
||||||
|
# if you are running background services, make sure this number is high enough to accept the task influx during peaks
|
||||||
|
# i.e. this should be higher than the number of wsjt services running at the same time
|
||||||
|
wsjt_queue_length = 10
|
||||||
|
# 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}
|
||||||
|
|
||||||
|
temporary_directory = "/tmp"
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
# path to the aprs symbols repository (get it here: https://github.com/hessu/aprs-symbols)
|
||||||
|
aprs_symbols_path = "/opt/aprs-symbols/png"
|
||||||
|
|
||||||
|
# === PSK Reporter setting ===
|
||||||
|
# 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"
|
||||||
|
|||||||
772
csdr.py
Executable file → Normal file
@@ -21,33 +21,66 @@ OpenWebRX csdr plugin: do the signal processing with csdr
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
import os
|
import os
|
||||||
import code
|
|
||||||
import signal
|
import signal
|
||||||
import fcntl
|
import threading
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
class dsp:
|
from owrx.kiss import KissClient, DirewolfConfig
|
||||||
|
from owrx.wsjt import Ft8Chopper, WsprChopper, Jt9Chopper, Jt65Chopper, Ft4Chopper
|
||||||
|
|
||||||
def __init__(self):
|
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)).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 = read()
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class dsp(object):
|
||||||
|
def __init__(self, output):
|
||||||
self.samp_rate = 250000
|
self.samp_rate = 250000
|
||||||
self.output_rate = 11025 #this is default, and cannot be set at the moment
|
self.output_rate = 11025
|
||||||
self.fft_size = 1024
|
self.fft_size = 1024
|
||||||
self.fft_fps = 5
|
self.fft_fps = 5
|
||||||
self.offset_freq = 0
|
self.offset_freq = 0
|
||||||
self.low_cut = -4000
|
self.low_cut = -4000
|
||||||
self.high_cut = 4000
|
self.high_cut = 4000
|
||||||
self.bpf_transition_bw = 320 #Hz, and this is a constant
|
self.bpf_transition_bw = 320 # Hz, and this is a constant
|
||||||
self.ddc_transition_bw_rate = 0.15 # of the IF sample rate
|
self.ddc_transition_bw_rate = 0.15 # of the IF sample rate
|
||||||
self.running = False
|
self.running = False
|
||||||
self.secondary_processes_running = False
|
self.secondary_processes_running = False
|
||||||
self.audio_compression = "none"
|
self.audio_compression = "none"
|
||||||
self.fft_compression = "none"
|
self.fft_compression = "none"
|
||||||
self.demodulator = "nfm"
|
self.demodulator = "nfm"
|
||||||
self.name = "csdr"
|
self.name = "csdr"
|
||||||
self.format_conversion = "csdr convert_u8_f"
|
|
||||||
self.base_bufsize = 512
|
self.base_bufsize = 512
|
||||||
self.nc_port = 4951
|
self.nc_port = None
|
||||||
self.csdr_dynamic_bufsize = False
|
self.csdr_dynamic_bufsize = False
|
||||||
self.csdr_print_bufsizes = False
|
self.csdr_print_bufsizes = False
|
||||||
self.csdr_through = False
|
self.csdr_through = False
|
||||||
@@ -59,68 +92,185 @@ class dsp:
|
|||||||
self.secondary_fft_size = 1024
|
self.secondary_fft_size = 1024
|
||||||
self.secondary_process_fft = None
|
self.secondary_process_fft = None
|
||||||
self.secondary_process_demod = None
|
self.secondary_process_demod = None
|
||||||
self.pipe_names=["bpf_pipe", "shift_pipe", "squelch_pipe", "smeter_pipe", "iqtee_pipe", "iqtee2_pipe"]
|
self.pipe_names = [
|
||||||
self.secondary_pipe_names=["secondary_shift_pipe"]
|
"bpf_pipe",
|
||||||
|
"shift_pipe",
|
||||||
|
"squelch_pipe",
|
||||||
|
"smeter_pipe",
|
||||||
|
"meta_pipe",
|
||||||
|
"iqtee_pipe",
|
||||||
|
"iqtee2_pipe",
|
||||||
|
"dmr_control_pipe",
|
||||||
|
]
|
||||||
|
self.secondary_pipe_names = ["secondary_shift_pipe"]
|
||||||
self.secondary_offset_freq = 1000
|
self.secondary_offset_freq = 1000
|
||||||
|
self.unvoiced_quality = 1
|
||||||
|
self.modification_lock = threading.Lock()
|
||||||
|
self.output = output
|
||||||
|
self.temporary_directory = "/tmp"
|
||||||
|
self.is_service = False
|
||||||
|
self.direwolf_config = None
|
||||||
|
self.direwolf_port = None
|
||||||
|
|
||||||
def chain(self,which):
|
def set_service(self, flag=True):
|
||||||
any_chain_base="nc -v 127.0.0.1 {nc_port} | "
|
self.is_service = flag
|
||||||
if self.csdr_dynamic_bufsize: any_chain_base+="csdr setbuf {start_bufsize} | "
|
|
||||||
if self.csdr_through: any_chain_base+="csdr through | "
|
def set_temporary_directory(self, what):
|
||||||
any_chain_base+=self.format_conversion+(" | " if self.format_conversion!="" else "") ##"csdr flowcontrol {flowcontrol} auto 1.5 10 | "
|
self.temporary_directory = what
|
||||||
|
|
||||||
|
def chain(self, which):
|
||||||
|
chain = ["nc -v 127.0.0.1 {nc_port}"]
|
||||||
|
if self.csdr_dynamic_bufsize:
|
||||||
|
chain += ["csdr setbuf {start_bufsize}"]
|
||||||
|
if self.csdr_through:
|
||||||
|
chain += ["csdr through"]
|
||||||
if which == "fft":
|
if which == "fft":
|
||||||
fft_chain_base = any_chain_base+"csdr fft_cc {fft_size} {fft_block_size} | " + \
|
chain += [
|
||||||
("csdr logpower_cf -70 | " if self.fft_averages == 0 else "csdr logaveragepower_cf -70 {fft_size} {fft_averages} | ") + \
|
"csdr fft_cc {fft_size} {fft_block_size}",
|
||||||
"csdr fft_exchange_sides_ff {fft_size}"
|
"csdr logpower_cf -70"
|
||||||
if self.fft_compression=="adpcm":
|
if self.fft_averages == 0
|
||||||
return fft_chain_base+" | csdr compress_fft_adpcm_f_u8 {fft_size}"
|
else "csdr logaveragepower_cf -70 {fft_size} {fft_averages}",
|
||||||
else:
|
"csdr fft_exchange_sides_ff {fft_size}",
|
||||||
return fft_chain_base
|
]
|
||||||
chain_begin=any_chain_base+"csdr shift_addition_cc --fifo {shift_pipe} | csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING | csdr bandpass_fir_fft_cc --fifo {bpf_pipe} {bpf_transition_bw} HAMMING | csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 1 | "
|
if self.fft_compression == "adpcm":
|
||||||
|
chain += ["csdr compress_fft_adpcm_f_u8 {fft_size}"]
|
||||||
|
return chain
|
||||||
|
chain += ["csdr shift_addition_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.secondary_demodulator:
|
||||||
chain_begin+="csdr tee {iqtee_pipe} | "
|
if self.output.supports_type("secondary_fft"):
|
||||||
chain_begin+="csdr tee {iqtee2_pipe} | "
|
chain += ["csdr tee {iqtee_pipe}"]
|
||||||
chain_end = ""
|
chain += ["csdr tee {iqtee2_pipe}"]
|
||||||
if self.audio_compression=="adpcm":
|
# early exit if we don't want audio
|
||||||
chain_end = " | csdr encode_ima_adpcm_i16_u8"
|
if not self.output.supports_type("audio"):
|
||||||
if which == "nfm": return chain_begin + "csdr fmdemod_quadri_cf | csdr limit_ff | csdr old_fractional_decimator_ff {last_decimation} | csdr deemphasis_nfm_ff 11025 | csdr fastagc_ff 1024 | csdr convert_f_s16"+chain_end
|
return chain
|
||||||
elif which == "am": return chain_begin + "csdr amdemod_cf | csdr fastdcblock_ff | csdr old_fractional_decimator_ff {last_decimation} | csdr agc_ff | csdr limit_ff | csdr convert_f_s16"+chain_end
|
# safe some cpu cycles... no need to decimate if decimation factor is 1
|
||||||
elif which == "ssb": return chain_begin + "csdr realpart_cf | csdr old_fractional_decimator_ff {last_decimation} | csdr agc_ff | csdr limit_ff | csdr convert_f_s16"+chain_end
|
last_decimation_block = (
|
||||||
|
["csdr fractional_decimator_ff {last_decimation}"] if self.last_decimation != 1.0 else []
|
||||||
|
)
|
||||||
|
if which == "nfm":
|
||||||
|
chain += ["csdr fmdemod_quadri_cf", "csdr limit_ff"]
|
||||||
|
chain += last_decimation_block
|
||||||
|
chain += ["csdr deemphasis_nfm_ff {audio_rate}"]
|
||||||
|
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 self.isDigitalVoice(which):
|
||||||
|
chain += ["csdr fmdemod_quadri_cf", "dc_block "]
|
||||||
|
chain += last_decimation_block
|
||||||
|
# dsd modes
|
||||||
|
if which in ["dstar", "nxdn"]:
|
||||||
|
chain += ["csdr limit_ff", "csdr convert_f_s16"]
|
||||||
|
if which == "dstar":
|
||||||
|
chain += ["dsd -fd -i - -o - -u {unvoiced_quality} -g -1 "]
|
||||||
|
elif which == "nxdn":
|
||||||
|
chain += ["dsd -fi -i - -o - -u {unvoiced_quality} -g -1 "]
|
||||||
|
chain += ["CSDR_FIXED_BUFSIZE=32 csdr convert_s16_f"]
|
||||||
|
max_gain = 5
|
||||||
|
# digiham modes
|
||||||
|
else:
|
||||||
|
chain += ["rrc_filter", "gfsk_demodulator"]
|
||||||
|
if which == "dmr":
|
||||||
|
chain += [
|
||||||
|
"dmr_decoder --fifo {meta_pipe} --control-fifo {dmr_control_pipe}",
|
||||||
|
"mbe_synthesizer -f -u {unvoiced_quality}",
|
||||||
|
]
|
||||||
|
elif which == "ysf":
|
||||||
|
chain += ["ysf_decoder --fifo {meta_pipe}", "mbe_synthesizer -y -f -u {unvoiced_quality}"]
|
||||||
|
max_gain = 0.0005
|
||||||
|
chain += [
|
||||||
|
"digitalvoice_filter -f",
|
||||||
|
"CSDR_FIXED_BUFSIZE=32 csdr agc_ff 160000 0.8 1 0.0000001 {max_gain}".format(max_gain=max_gain),
|
||||||
|
"sox -t raw -r 8000 -e floating-point -b 32 -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", "csdr limit_ff", "csdr convert_f_s16"]
|
||||||
|
elif which == "ssb":
|
||||||
|
chain += ["csdr realpart_cf"]
|
||||||
|
chain += last_decimation_block
|
||||||
|
chain += ["csdr agc_ff", "csdr limit_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):
|
def secondary_chain(self, which):
|
||||||
secondary_chain_base="cat {input_pipe} | "
|
secondary_chain_base = "cat {input_pipe} | "
|
||||||
if which == "fft":
|
if which == "fft":
|
||||||
return secondary_chain_base+"csdr realpart_cf | csdr fft_fc {secondary_fft_input_size} {secondary_fft_block_size} | csdr logpower_cf -70 " + (" | csdr compress_fft_adpcm_f_u8 {secondary_fft_size}" if self.fft_compression=="adpcm" else "")
|
return (
|
||||||
|
secondary_chain_base
|
||||||
|
+ "csdr realpart_cf | csdr fft_fc {secondary_fft_input_size} {secondary_fft_block_size} | csdr logpower_cf -70 "
|
||||||
|
+ (" | csdr compress_fft_adpcm_f_u8 {secondary_fft_size}" if self.fft_compression == "adpcm" else "")
|
||||||
|
)
|
||||||
elif which == "bpsk31":
|
elif which == "bpsk31":
|
||||||
return secondary_chain_base + "csdr shift_addition_cc --fifo {secondary_shift_pipe} | " + \
|
return (
|
||||||
"csdr bandpass_fir_fft_cc $(csdr '=-(31.25)/{if_samp_rate}') $(csdr '=(31.25)/{if_samp_rate}') $(csdr '=31.25/{if_samp_rate}') | " + \
|
secondary_chain_base
|
||||||
"csdr simple_agc_cc 0.001 0.5 | " + \
|
+ "csdr shift_addition_cc --fifo {secondary_shift_pipe} | "
|
||||||
"csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | " + \
|
+ "csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff} | "
|
||||||
"CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | " + \
|
+ "csdr simple_agc_cc 0.001 0.5 | "
|
||||||
"CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8"
|
+ "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):
|
||||||
|
chain = secondary_chain_base + "csdr realpart_cf | "
|
||||||
|
if self.last_decimation != 1.0:
|
||||||
|
chain += "csdr fractional_decimator_ff {last_decimation} | "
|
||||||
|
chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16"
|
||||||
|
return chain
|
||||||
|
elif which == "packet":
|
||||||
|
chain = secondary_chain_base + "csdr fmdemod_quadri_cf | "
|
||||||
|
if self.last_decimation != 1.0:
|
||||||
|
chain += "csdr fractional_decimator_ff {last_decimation} | "
|
||||||
|
chain += "csdr convert_f_s16 | direwolf -c {direwolf_config} -r {audio_rate} -t 0 -q d -q h - 1>&2"
|
||||||
|
return chain
|
||||||
|
|
||||||
def set_secondary_demodulator(self, what):
|
def set_secondary_demodulator(self, what):
|
||||||
|
if self.get_secondary_demodulator() == what:
|
||||||
|
return
|
||||||
self.secondary_demodulator = what
|
self.secondary_demodulator = what
|
||||||
|
self.calculate_decimation()
|
||||||
|
self.restart()
|
||||||
|
|
||||||
def secondary_fft_block_size(self):
|
def secondary_fft_block_size(self):
|
||||||
return (self.samp_rate/self.decimation)/(self.fft_fps*2) #*2 is there because we do FFT on real signal here
|
return (self.samp_rate / self.decimation) / (
|
||||||
|
self.fft_fps * 2
|
||||||
|
) # *2 is there because we do FFT on real signal here
|
||||||
|
|
||||||
def secondary_decimation(self):
|
def secondary_decimation(self):
|
||||||
return 1 #currently unused
|
return 1 # currently unused
|
||||||
|
|
||||||
def secondary_bpf_cutoff(self):
|
def secondary_bpf_cutoff(self):
|
||||||
if self.secondary_demodulator == "bpsk31":
|
if self.secondary_demodulator == "bpsk31":
|
||||||
return (31.25/2) / self.if_samp_rate()
|
return 31.25 / self.if_samp_rate()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def secondary_bpf_transition_bw(self):
|
def secondary_bpf_transition_bw(self):
|
||||||
if self.secondary_demodulator == "bpsk31":
|
if self.secondary_demodulator == "bpsk31":
|
||||||
return (31.25/2) / self.if_samp_rate()
|
return 31.25 / self.if_samp_rate()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def secondary_samples_per_bits(self):
|
def secondary_samples_per_bits(self):
|
||||||
if self.secondary_demodulator == "bpsk31":
|
if self.secondary_demodulator == "bpsk31":
|
||||||
return int(round(self.if_samp_rate()/31.25))&~3
|
return int(round(self.if_samp_rate() / 31.25)) & ~3
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def secondary_bw(self):
|
def secondary_bw(self):
|
||||||
@@ -128,102 +278,153 @@ class dsp:
|
|||||||
return 31.25
|
return 31.25
|
||||||
|
|
||||||
def start_secondary_demodulator(self):
|
def start_secondary_demodulator(self):
|
||||||
if(not self.secondary_demodulator): return
|
if not self.secondary_demodulator:
|
||||||
print "[openwebrx] starting secondary demodulator from IF input sampled at %d"%self.if_samp_rate()
|
return
|
||||||
secondary_command_fft=self.secondary_chain("fft")
|
logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate())
|
||||||
secondary_command_demod=self.secondary_chain(self.secondary_demodulator)
|
secondary_command_demod = self.secondary_chain(self.secondary_demodulator)
|
||||||
self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod + secondary_command_fft)
|
self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod)
|
||||||
|
self.try_create_configs(secondary_command_demod)
|
||||||
|
|
||||||
secondary_command_fft=secondary_command_fft.format( \
|
secondary_command_demod = secondary_command_demod.format(
|
||||||
input_pipe=self.iqtee_pipe, \
|
input_pipe=self.iqtee2_pipe,
|
||||||
secondary_fft_input_size=self.secondary_fft_size, \
|
secondary_shift_pipe=self.secondary_shift_pipe,
|
||||||
secondary_fft_size=self.secondary_fft_size, \
|
secondary_decimation=self.secondary_decimation(),
|
||||||
secondary_fft_block_size=self.secondary_fft_block_size(), \
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("secondary command (demod) = %s", secondary_command_demod)
|
||||||
|
my_env = os.environ.copy()
|
||||||
|
# if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1";
|
||||||
|
if self.csdr_print_bufsizes:
|
||||||
|
my_env["CSDR_PRINT_BUFSIZES"] = "1"
|
||||||
|
if self.output.supports_type("secondary_fft"):
|
||||||
|
secondary_command_fft = self.secondary_chain("fft")
|
||||||
|
secondary_command_fft = secondary_command_fft.format(
|
||||||
|
input_pipe=self.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(),
|
||||||
)
|
)
|
||||||
secondary_command_demod=secondary_command_demod.format( \
|
logger.debug("secondary command (fft) = %s", secondary_command_fft)
|
||||||
input_pipe=self.iqtee2_pipe, \
|
|
||||||
secondary_shift_pipe=self.secondary_shift_pipe, \
|
self.secondary_process_fft = subprocess.Popen(
|
||||||
secondary_decimation=self.secondary_decimation(), \
|
secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env
|
||||||
secondary_samples_per_bits=self.secondary_samples_per_bits(), \
|
)
|
||||||
secondary_bpf_cutoff=self.secondary_bpf_cutoff(), \
|
self.output.send_output(
|
||||||
secondary_bpf_transition_bw=self.secondary_bpf_transition_bw(), \
|
"secondary_fft",
|
||||||
if_samp_rate=self.if_samp_rate()
|
partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())),
|
||||||
)
|
)
|
||||||
|
|
||||||
print "[openwebrx-dsp-plugin:csdr] secondary command (fft) =", secondary_command_fft
|
# direwolf does not provide any meaningful data on stdout
|
||||||
print "[openwebrx-dsp-plugin:csdr] secondary command (demod) =", secondary_command_demod
|
# more specifically, it doesn't provide any data. if however, for any strange reason, it would start to do so,
|
||||||
#code.interact(local=locals())
|
# it would block if not read. by piping it to devnull, we avoid a potential pitfall here.
|
||||||
my_env=os.environ.copy()
|
secondary_output = subprocess.DEVNULL if self.isPacket() else subprocess.PIPE
|
||||||
#if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1";
|
self.secondary_process_demod = subprocess.Popen(
|
||||||
if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"]="1";
|
secondary_command_demod, stdout=secondary_output, shell=True, preexec_fn=os.setpgrp, env=my_env
|
||||||
self.secondary_process_fft = subprocess.Popen(secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env)
|
)
|
||||||
print "[openwebrx-dsp-plugin:csdr] Popen on secondary command (fft)"
|
|
||||||
self.secondary_process_demod = subprocess.Popen(secondary_command_demod, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env) #TODO digimodes
|
|
||||||
print "[openwebrx-dsp-plugin:csdr] Popen on secondary command (demod)" #TODO digimodes
|
|
||||||
self.secondary_processes_running = True
|
self.secondary_processes_running = True
|
||||||
|
|
||||||
#open control pipes for csdr and send initialization data
|
if self.isWsjtMode():
|
||||||
# print "==========> 1"
|
smd = self.get_secondary_demodulator()
|
||||||
if self.secondary_shift_pipe != None: #TODO digimodes
|
if smd == "ft8":
|
||||||
# print "==========> 2", self.secondary_shift_pipe
|
chopper = Ft8Chopper(self.secondary_process_demod.stdout)
|
||||||
self.secondary_shift_pipe_file=open(self.secondary_shift_pipe,"w") #TODO digimodes
|
elif smd == "wspr":
|
||||||
# print "==========> 3"
|
chopper = WsprChopper(self.secondary_process_demod.stdout)
|
||||||
self.set_secondary_offset_freq(self.secondary_offset_freq) #TODO digimodes
|
elif smd == "jt65":
|
||||||
# print "==========> 4"
|
chopper = Jt65Chopper(self.secondary_process_demod.stdout)
|
||||||
|
elif smd == "jt9":
|
||||||
|
chopper = Jt9Chopper(self.secondary_process_demod.stdout)
|
||||||
|
elif smd == "ft4":
|
||||||
|
chopper = Ft4Chopper(self.secondary_process_demod.stdout)
|
||||||
|
chopper.start()
|
||||||
|
self.output.send_output("wsjt_demod", chopper.read)
|
||||||
|
elif self.isPacket():
|
||||||
|
# we best get the ax25 packets from the kiss socket
|
||||||
|
kiss = KissClient(self.direwolf_port)
|
||||||
|
self.output.send_output("packet_demod", kiss.read)
|
||||||
|
else:
|
||||||
|
self.output.send_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1))
|
||||||
|
|
||||||
self.set_pipe_nonblocking(self.secondary_process_demod.stdout)
|
# open control pipes for csdr and send initialization data
|
||||||
self.set_pipe_nonblocking(self.secondary_process_fft.stdout)
|
if self.secondary_shift_pipe != None: # TODO digimodes
|
||||||
|
self.secondary_shift_pipe_file = open(self.secondary_shift_pipe, "w") # TODO digimodes
|
||||||
|
self.set_secondary_offset_freq(self.secondary_offset_freq) # TODO digimodes
|
||||||
|
|
||||||
def set_secondary_offset_freq(self, value):
|
def set_secondary_offset_freq(self, value):
|
||||||
self.secondary_offset_freq=value
|
self.secondary_offset_freq = value
|
||||||
if self.secondary_processes_running:
|
if self.secondary_processes_running and hasattr(self, "secondary_shift_pipe_file"):
|
||||||
self.secondary_shift_pipe_file.write("%g\n"%(-float(self.secondary_offset_freq)/self.if_samp_rate()))
|
self.secondary_shift_pipe_file.write("%g\n" % (-float(self.secondary_offset_freq) / self.if_samp_rate()))
|
||||||
self.secondary_shift_pipe_file.flush()
|
self.secondary_shift_pipe_file.flush()
|
||||||
|
|
||||||
def stop_secondary_demodulator(self):
|
def stop_secondary_demodulator(self):
|
||||||
if self.secondary_processes_running == False: return
|
if self.secondary_processes_running == False:
|
||||||
|
return
|
||||||
self.try_delete_pipes(self.secondary_pipe_names)
|
self.try_delete_pipes(self.secondary_pipe_names)
|
||||||
if self.secondary_process_fft: os.killpg(os.getpgid(self.secondary_process_fft.pid), signal.SIGTERM)
|
self.try_delete_configs()
|
||||||
if self.secondary_process_demod: os.killpg(os.getpgid(self.secondary_process_demod.pid), signal.SIGTERM)
|
if self.secondary_process_fft:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(self.secondary_process_fft.pid), signal.SIGTERM)
|
||||||
|
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)
|
||||||
|
except ProcessLookupError:
|
||||||
|
# been killed by something else, ignore
|
||||||
|
pass
|
||||||
self.secondary_processes_running = False
|
self.secondary_processes_running = False
|
||||||
|
|
||||||
def read_secondary_demod(self, size):
|
|
||||||
return self.secondary_process_demod.stdout.read(size)
|
|
||||||
|
|
||||||
def read_secondary_fft(self, size):
|
|
||||||
return self.secondary_process_fft.stdout.read(size)
|
|
||||||
|
|
||||||
def get_secondary_demodulator(self):
|
def get_secondary_demodulator(self):
|
||||||
return self.secondary_demodulator
|
return self.secondary_demodulator
|
||||||
|
|
||||||
def set_secondary_fft_size(self,secondary_fft_size):
|
def set_secondary_fft_size(self, secondary_fft_size):
|
||||||
#to change this, restart is required
|
# to change this, restart is required
|
||||||
self.secondary_fft_size=secondary_fft_size
|
self.secondary_fft_size = secondary_fft_size
|
||||||
|
|
||||||
def set_audio_compression(self,what):
|
def set_audio_compression(self, what):
|
||||||
self.audio_compression = what
|
self.audio_compression = what
|
||||||
|
|
||||||
def set_fft_compression(self,what):
|
def set_fft_compression(self, what):
|
||||||
self.fft_compression = what
|
self.fft_compression = what
|
||||||
|
|
||||||
def get_fft_bytes_to_read(self):
|
def get_fft_bytes_to_read(self):
|
||||||
if self.fft_compression=="none": return self.fft_size*4
|
if self.fft_compression == "none":
|
||||||
if self.fft_compression=="adpcm": return (self.fft_size/2)+(10/2)
|
return self.fft_size * 4
|
||||||
|
if self.fft_compression == "adpcm":
|
||||||
|
return (self.fft_size / 2) + (10 / 2)
|
||||||
|
|
||||||
def get_secondary_fft_bytes_to_read(self):
|
def get_secondary_fft_bytes_to_read(self):
|
||||||
if self.fft_compression=="none": return self.secondary_fft_size*4
|
if self.fft_compression == "none":
|
||||||
if self.fft_compression=="adpcm": return (self.secondary_fft_size/2)+(10/2)
|
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):
|
def set_samp_rate(self, samp_rate):
|
||||||
#to change this, restart is required
|
self.samp_rate = samp_rate
|
||||||
self.samp_rate=samp_rate
|
self.calculate_decimation()
|
||||||
self.decimation=1
|
if self.running:
|
||||||
while self.samp_rate/(self.decimation+1)>self.output_rate:
|
self.restart()
|
||||||
self.decimation+=1
|
|
||||||
self.last_decimation=float(self.if_samp_rate())/self.output_rate
|
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):
|
||||||
|
decimation = 1
|
||||||
|
while input_rate / (decimation + 1) >= output_rate:
|
||||||
|
decimation += 1
|
||||||
|
fraction = float(input_rate / decimation) / output_rate
|
||||||
|
intermediate_rate = input_rate / decimation
|
||||||
|
return (decimation, fraction, intermediate_rate)
|
||||||
|
|
||||||
def if_samp_rate(self):
|
def if_samp_rate(self):
|
||||||
return self.samp_rate/self.decimation
|
return self.samp_rate / self.decimation
|
||||||
|
|
||||||
def get_name(self):
|
def get_name(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -231,64 +432,105 @@ class dsp:
|
|||||||
def get_output_rate(self):
|
def get_output_rate(self):
|
||||||
return self.output_rate
|
return self.output_rate
|
||||||
|
|
||||||
def set_output_rate(self,output_rate):
|
def get_audio_rate(self):
|
||||||
self.output_rate=output_rate
|
if self.isDigitalVoice() or self.isPacket():
|
||||||
self.set_samp_rate(self.samp_rate) #as it depends on output_rate
|
return 48000
|
||||||
|
elif self.isWsjtMode():
|
||||||
|
return 12000
|
||||||
|
return self.get_output_rate()
|
||||||
|
|
||||||
def set_demodulator(self,demodulator):
|
def isDigitalVoice(self, demodulator=None):
|
||||||
#to change this, restart is required
|
if demodulator is None:
|
||||||
self.demodulator=demodulator
|
demodulator = self.get_demodulator()
|
||||||
|
return demodulator in ["dmr", "dstar", "nxdn", "ysf"]
|
||||||
|
|
||||||
|
def isWsjtMode(self, demodulator=None):
|
||||||
|
if demodulator is None:
|
||||||
|
demodulator = self.get_secondary_demodulator()
|
||||||
|
return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4"]
|
||||||
|
|
||||||
|
def isPacket(self, demodulator=None):
|
||||||
|
if demodulator is None:
|
||||||
|
demodulator = self.get_secondary_demodulator()
|
||||||
|
return demodulator == "packet"
|
||||||
|
|
||||||
|
def set_output_rate(self, output_rate):
|
||||||
|
self.output_rate = output_rate
|
||||||
|
self.calculate_decimation()
|
||||||
|
|
||||||
|
def set_demodulator(self, demodulator):
|
||||||
|
if self.demodulator == demodulator:
|
||||||
|
return
|
||||||
|
self.demodulator = demodulator
|
||||||
|
self.calculate_decimation()
|
||||||
|
self.restart()
|
||||||
|
|
||||||
def get_demodulator(self):
|
def get_demodulator(self):
|
||||||
return self.demodulator
|
return self.demodulator
|
||||||
|
|
||||||
def set_fft_size(self,fft_size):
|
def set_fft_size(self, fft_size):
|
||||||
#to change this, restart is required
|
self.fft_size = fft_size
|
||||||
self.fft_size=fft_size
|
self.restart()
|
||||||
|
|
||||||
def set_fft_fps(self,fft_fps):
|
def set_fft_fps(self, fft_fps):
|
||||||
#to change this, restart is required
|
self.fft_fps = fft_fps
|
||||||
self.fft_fps=fft_fps
|
self.restart()
|
||||||
|
|
||||||
def set_fft_averages(self,fft_averages):
|
def set_fft_averages(self, fft_averages):
|
||||||
#to change this, restart is required
|
self.fft_averages = fft_averages
|
||||||
self.fft_averages=fft_averages
|
self.restart()
|
||||||
|
|
||||||
def fft_block_size(self):
|
def fft_block_size(self):
|
||||||
if self.fft_averages == 0: return self.samp_rate/self.fft_fps
|
if self.fft_averages == 0:
|
||||||
else: return self.samp_rate/self.fft_fps/self.fft_averages
|
return self.samp_rate / self.fft_fps
|
||||||
|
else:
|
||||||
|
return self.samp_rate / self.fft_fps / self.fft_averages
|
||||||
|
|
||||||
def set_format_conversion(self,format_conversion):
|
def set_offset_freq(self, offset_freq):
|
||||||
self.format_conversion=format_conversion
|
self.offset_freq = offset_freq
|
||||||
|
|
||||||
def set_offset_freq(self,offset_freq):
|
|
||||||
self.offset_freq=offset_freq
|
|
||||||
if self.running:
|
if self.running:
|
||||||
self.shift_pipe_file.write("%g\n"%(-float(self.offset_freq)/self.samp_rate))
|
self.modification_lock.acquire()
|
||||||
|
self.shift_pipe_file.write("%g\n" % (-float(self.offset_freq) / self.samp_rate))
|
||||||
self.shift_pipe_file.flush()
|
self.shift_pipe_file.flush()
|
||||||
|
self.modification_lock.release()
|
||||||
|
|
||||||
def set_bpf(self,low_cut,high_cut):
|
def set_bpf(self, low_cut, high_cut):
|
||||||
self.low_cut=low_cut
|
self.low_cut = low_cut
|
||||||
self.high_cut=high_cut
|
self.high_cut = high_cut
|
||||||
if self.running:
|
if self.running:
|
||||||
self.bpf_pipe_file.write( "%g %g\n"%(float(self.low_cut)/self.if_samp_rate(), float(self.high_cut)/self.if_samp_rate()) )
|
self.modification_lock.acquire()
|
||||||
|
self.bpf_pipe_file.write(
|
||||||
|
"%g %g\n" % (float(self.low_cut) / self.if_samp_rate(), float(self.high_cut) / self.if_samp_rate())
|
||||||
|
)
|
||||||
self.bpf_pipe_file.flush()
|
self.bpf_pipe_file.flush()
|
||||||
|
self.modification_lock.release()
|
||||||
|
|
||||||
def get_bpf(self):
|
def get_bpf(self):
|
||||||
return [self.low_cut, self.high_cut]
|
return [self.low_cut, self.high_cut]
|
||||||
|
|
||||||
def set_squelch_level(self, squelch_level):
|
def set_squelch_level(self, squelch_level):
|
||||||
self.squelch_level=squelch_level
|
self.squelch_level = squelch_level
|
||||||
|
# no squelch required on digital voice modes
|
||||||
|
actual_squelch = 0 if self.isDigitalVoice() or self.isPacket() else self.squelch_level
|
||||||
if self.running:
|
if self.running:
|
||||||
self.squelch_pipe_file.write( "%g\n"%(float(self.squelch_level)) )
|
self.modification_lock.acquire()
|
||||||
|
self.squelch_pipe_file.write("%g\n" % (float(actual_squelch)))
|
||||||
self.squelch_pipe_file.flush()
|
self.squelch_pipe_file.flush()
|
||||||
|
self.modification_lock.release()
|
||||||
|
|
||||||
def get_smeter_level(self):
|
def set_unvoiced_quality(self, q):
|
||||||
if self.running:
|
self.unvoiced_quality = q
|
||||||
line=self.smeter_pipe_file.readline()
|
self.restart()
|
||||||
return float(line[:-1])
|
|
||||||
|
|
||||||
def mkfifo(self,path):
|
def get_unvoiced_quality(self):
|
||||||
|
return self.unvoiced_quality
|
||||||
|
|
||||||
|
def set_dmr_filter(self, filter):
|
||||||
|
if self.dmr_control_pipe_file:
|
||||||
|
self.dmr_control_pipe_file.write("{0}\n".format(filter))
|
||||||
|
self.dmr_control_pipe_file.flush()
|
||||||
|
|
||||||
|
def mkfifo(self, path):
|
||||||
try:
|
try:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
except:
|
except:
|
||||||
@@ -296,129 +538,185 @@ class dsp:
|
|||||||
os.mkfifo(path)
|
os.mkfifo(path)
|
||||||
|
|
||||||
def ddc_transition_bw(self):
|
def ddc_transition_bw(self):
|
||||||
return self.ddc_transition_bw_rate*(self.if_samp_rate()/float(self.samp_rate))
|
return self.ddc_transition_bw_rate * (self.if_samp_rate() / float(self.samp_rate))
|
||||||
|
|
||||||
def try_create_pipes(self, pipe_names, command_base):
|
def try_create_pipes(self, pipe_names, command_base):
|
||||||
# print "try_create_pipes"
|
|
||||||
for pipe_name in pipe_names:
|
for pipe_name in pipe_names:
|
||||||
# print "\t"+pipe_name
|
if "{" + pipe_name + "}" in command_base:
|
||||||
if "{"+pipe_name+"}" in command_base:
|
setattr(self, pipe_name, self.pipe_base_path + pipe_name)
|
||||||
setattr(self, pipe_name, self.pipe_base_path+pipe_name)
|
|
||||||
self.mkfifo(getattr(self, pipe_name))
|
self.mkfifo(getattr(self, pipe_name))
|
||||||
else:
|
else:
|
||||||
setattr(self, pipe_name, None)
|
setattr(self, pipe_name, None)
|
||||||
|
|
||||||
def try_delete_pipes(self, pipe_names):
|
def try_delete_pipes(self, pipe_names):
|
||||||
for pipe_name in pipe_names:
|
for pipe_name in pipe_names:
|
||||||
pipe_path = getattr(self,pipe_name,None)
|
pipe_path = getattr(self, pipe_name, None)
|
||||||
if pipe_path:
|
if pipe_path:
|
||||||
try: os.unlink(pipe_path)
|
try:
|
||||||
except Exception as e: print "[openwebrx-dsp-plugin:csdr] try_delete_pipes() ::", e
|
os.unlink(pipe_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# it seems like we keep calling this twice. no idea why, but we don't need the resulting error.
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("try_delete_pipes()")
|
||||||
|
|
||||||
def set_pipe_nonblocking(self, pipe):
|
def try_create_configs(self, command):
|
||||||
flags = fcntl.fcntl(pipe, fcntl.F_GETFL)
|
if "{direwolf_config}" in command:
|
||||||
fcntl.fcntl(pipe, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
self.direwolf_config = "{tmp_dir}/openwebrx_direwolf_{myid}.conf".format(
|
||||||
|
tmp_dir=self.temporary_directory, myid=id(self)
|
||||||
|
)
|
||||||
|
self.direwolf_port = KissClient.getFreePort()
|
||||||
|
file = open(self.direwolf_config, "w")
|
||||||
|
file.write(DirewolfConfig().getConfig(self.direwolf_port, self.is_service))
|
||||||
|
file.close()
|
||||||
|
else:
|
||||||
|
self.direwolf_config = None
|
||||||
|
self.direwolf_port = None
|
||||||
|
|
||||||
|
def try_delete_configs(self):
|
||||||
|
if self.direwolf_config:
|
||||||
|
try:
|
||||||
|
os.unlink(self.direwolf_config)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# result suits our expectations. fine :)
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("try_delete_configs()")
|
||||||
|
self.direwolf_config = None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
command_base=self.chain(self.demodulator)
|
self.modification_lock.acquire()
|
||||||
|
if self.running:
|
||||||
|
self.modification_lock.release()
|
||||||
|
return
|
||||||
|
self.running = True
|
||||||
|
|
||||||
#create control pipes for csdr
|
command_base = " | ".join(self.chain(self.demodulator))
|
||||||
self.pipe_base_path="/tmp/openwebrx_pipe_{myid}_".format(myid=id(self))
|
|
||||||
# self.bpf_pipe = self.shift_pipe = self.squelch_pipe = self.smeter_pipe = None
|
# create control pipes for csdr
|
||||||
|
self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_{myid}_".format(tmp_dir=self.temporary_directory, myid=id(self))
|
||||||
|
|
||||||
self.try_create_pipes(self.pipe_names, command_base)
|
self.try_create_pipes(self.pipe_names, command_base)
|
||||||
|
|
||||||
# if "{bpf_pipe}" in command_base:
|
# run the command
|
||||||
# self.bpf_pipe=pipe_base_path+"bpf"
|
command = command_base.format(
|
||||||
# self.mkfifo(self.bpf_pipe)
|
bpf_pipe=self.bpf_pipe,
|
||||||
# if "{shift_pipe}" in command_base:
|
shift_pipe=self.shift_pipe,
|
||||||
# self.shift_pipe=pipe_base_path+"shift"
|
decimation=self.decimation,
|
||||||
# self.mkfifo(self.shift_pipe)
|
last_decimation=self.last_decimation,
|
||||||
# if "{squelch_pipe}" in command_base:
|
fft_size=self.fft_size,
|
||||||
# self.squelch_pipe=pipe_base_path+"squelch"
|
fft_block_size=self.fft_block_size(),
|
||||||
# self.mkfifo(self.squelch_pipe)
|
fft_averages=self.fft_averages,
|
||||||
# if "{smeter_pipe}" in command_base:
|
bpf_transition_bw=float(self.bpf_transition_bw) / self.if_samp_rate(),
|
||||||
# self.smeter_pipe=pipe_base_path+"smeter"
|
ddc_transition_bw=self.ddc_transition_bw(),
|
||||||
# self.mkfifo(self.smeter_pipe)
|
flowcontrol=int(self.samp_rate * 2),
|
||||||
# if "{iqtee_pipe}" in command_base:
|
start_bufsize=self.base_bufsize * self.decimation,
|
||||||
# self.iqtee_pipe=pipe_base_path+"iqtee"
|
nc_port=self.nc_port,
|
||||||
# self.mkfifo(self.iqtee_pipe)
|
squelch_pipe=self.squelch_pipe,
|
||||||
# if "{iqtee2_pipe}" in command_base:
|
smeter_pipe=self.smeter_pipe,
|
||||||
# self.iqtee2_pipe=pipe_base_path+"iqtee2"
|
meta_pipe=self.meta_pipe,
|
||||||
# self.mkfifo(self.iqtee2_pipe)
|
iqtee_pipe=self.iqtee_pipe,
|
||||||
|
iqtee2_pipe=self.iqtee2_pipe,
|
||||||
|
output_rate=self.get_output_rate(),
|
||||||
|
smeter_report_every=int(self.if_samp_rate() / 6000),
|
||||||
|
unvoiced_quality=self.get_unvoiced_quality(),
|
||||||
|
dmr_control_pipe=self.dmr_control_pipe,
|
||||||
|
audio_rate=self.get_audio_rate(),
|
||||||
|
)
|
||||||
|
|
||||||
#run the command
|
logger.debug("Command = %s", command)
|
||||||
command=command_base.format( bpf_pipe=self.bpf_pipe, shift_pipe=self.shift_pipe, decimation=self.decimation, \
|
my_env = os.environ.copy()
|
||||||
last_decimation=self.last_decimation, fft_size=self.fft_size, fft_block_size=self.fft_block_size(), fft_averages=self.fft_averages, \
|
if self.csdr_dynamic_bufsize:
|
||||||
bpf_transition_bw=float(self.bpf_transition_bw)/self.if_samp_rate(), ddc_transition_bw=self.ddc_transition_bw(), \
|
my_env["CSDR_DYNAMIC_BUFSIZE_ON"] = "1"
|
||||||
flowcontrol=int(self.samp_rate*2), start_bufsize=self.base_bufsize*self.decimation, nc_port=self.nc_port, \
|
if self.csdr_print_bufsizes:
|
||||||
squelch_pipe=self.squelch_pipe, smeter_pipe=self.smeter_pipe, iqtee_pipe=self.iqtee_pipe, iqtee2_pipe=self.iqtee2_pipe )
|
my_env["CSDR_PRINT_BUFSIZES"] = "1"
|
||||||
|
|
||||||
print "[openwebrx-dsp-plugin:csdr] Command =",command
|
out = subprocess.PIPE if self.output.supports_type("audio") else subprocess.DEVNULL
|
||||||
#code.interact(local=locals())
|
self.process = subprocess.Popen(command, stdout=out, shell=True, preexec_fn=os.setpgrp, env=my_env)
|
||||||
my_env=os.environ.copy()
|
|
||||||
if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1";
|
|
||||||
if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"]="1";
|
|
||||||
self.process = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env)
|
|
||||||
self.running = True
|
|
||||||
|
|
||||||
#open control pipes for csdr and send initialization data
|
def watch_thread():
|
||||||
if self.bpf_pipe != None:
|
rc = self.process.wait()
|
||||||
self.bpf_pipe_file=open(self.bpf_pipe,"w")
|
logger.debug("dsp thread ended with rc=%d", rc)
|
||||||
self.set_bpf(self.low_cut,self.high_cut)
|
if rc == 0 and self.running and not self.modification_lock.locked():
|
||||||
if self.shift_pipe != None:
|
logger.debug("restarting since rc = 0, self.running = true, and no modification")
|
||||||
self.shift_pipe_file=open(self.shift_pipe,"w")
|
self.restart()
|
||||||
self.set_offset_freq(self.offset_freq)
|
|
||||||
if self.squelch_pipe != None:
|
|
||||||
self.squelch_pipe_file=open(self.squelch_pipe,"w")
|
|
||||||
self.set_squelch_level(self.squelch_level)
|
|
||||||
if self.smeter_pipe != None:
|
|
||||||
self.smeter_pipe_file=open(self.smeter_pipe,"r")
|
|
||||||
self.set_pipe_nonblocking(self.smeter_pipe_file)
|
|
||||||
|
|
||||||
|
threading.Thread(target=watch_thread).start()
|
||||||
|
|
||||||
|
if self.output.supports_type("audio"):
|
||||||
|
self.output.send_output(
|
||||||
|
"audio",
|
||||||
|
partial(
|
||||||
|
self.process.stdout.read, int(self.get_fft_bytes_to_read()) if self.demodulator == "fft" else 256
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# open control pipes for csdr
|
||||||
|
if self.bpf_pipe:
|
||||||
|
self.bpf_pipe_file = open(self.bpf_pipe, "w")
|
||||||
|
if self.shift_pipe:
|
||||||
|
self.shift_pipe_file = open(self.shift_pipe, "w")
|
||||||
|
if self.squelch_pipe:
|
||||||
|
self.squelch_pipe_file = open(self.squelch_pipe, "w")
|
||||||
self.start_secondary_demodulator()
|
self.start_secondary_demodulator()
|
||||||
|
|
||||||
def read(self,size):
|
self.modification_lock.release()
|
||||||
return self.process.stdout.read(size)
|
|
||||||
|
# send initial config through the pipes
|
||||||
|
if self.squelch_pipe:
|
||||||
|
self.set_squelch_level(self.squelch_level)
|
||||||
|
if self.shift_pipe:
|
||||||
|
self.set_offset_freq(self.offset_freq)
|
||||||
|
if self.bpf_pipe:
|
||||||
|
self.set_bpf(self.low_cut, self.high_cut)
|
||||||
|
if self.smeter_pipe:
|
||||||
|
self.smeter_pipe_file = open(self.smeter_pipe, "r")
|
||||||
|
|
||||||
|
def read_smeter():
|
||||||
|
raw = self.smeter_pipe_file.readline()
|
||||||
|
if len(raw) == 0:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return float(raw.rstrip("\n"))
|
||||||
|
|
||||||
|
self.output.send_output("smeter", read_smeter)
|
||||||
|
if self.meta_pipe != None:
|
||||||
|
# TODO make digiham output unicode and then change this here
|
||||||
|
self.meta_pipe_file = open(self.meta_pipe, "r", encoding="cp437")
|
||||||
|
|
||||||
|
def read_meta():
|
||||||
|
raw = self.meta_pipe_file.readline()
|
||||||
|
if len(raw) == 0:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return raw.rstrip("\n")
|
||||||
|
|
||||||
|
self.output.send_output("meta", read_meta)
|
||||||
|
|
||||||
|
if self.dmr_control_pipe:
|
||||||
|
self.dmr_control_pipe_file = open(self.dmr_control_pipe, "w")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
self.modification_lock.acquire()
|
||||||
|
self.running = False
|
||||||
|
if hasattr(self, "process"):
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
# been killed by something else, ignore
|
||||||
|
pass
|
||||||
self.stop_secondary_demodulator()
|
self.stop_secondary_demodulator()
|
||||||
#if(self.process.poll()!=None):return # returns None while subprocess is running
|
|
||||||
#while(self.process.poll()==None):
|
|
||||||
# #self.process.kill()
|
|
||||||
# print "killproc",os.getpgid(self.process.pid),self.process.pid
|
|
||||||
# os.killpg(self.process.pid, signal.SIGTERM)
|
|
||||||
#
|
|
||||||
# time.sleep(0.1)
|
|
||||||
|
|
||||||
self.try_delete_pipes(self.pipe_names)
|
self.try_delete_pipes(self.pipe_names)
|
||||||
|
|
||||||
# if self.bpf_pipe:
|
self.modification_lock.release()
|
||||||
# try: os.unlink(self.bpf_pipe)
|
|
||||||
# except: print "[openwebrx-dsp-plugin:csdr] stop() :: unlink failed: " + self.bpf_pipe
|
|
||||||
# if self.shift_pipe:
|
|
||||||
# try: os.unlink(self.shift_pipe)
|
|
||||||
# except: print "[openwebrx-dsp-plugin:csdr] stop() :: unlink failed: " + self.shift_pipe
|
|
||||||
# if self.squelch_pipe:
|
|
||||||
# try: os.unlink(self.squelch_pipe)
|
|
||||||
# except: print "[openwebrx-dsp-plugin:csdr] stop() :: unlink failed: " + self.squelch_pipe
|
|
||||||
# if self.smeter_pipe:
|
|
||||||
# try: os.unlink(self.smeter_pipe)
|
|
||||||
# except: print "[openwebrx-dsp-plugin:csdr] stop() :: unlink failed: " + self.smeter_pipe
|
|
||||||
# if self.iqtee_pipe:
|
|
||||||
# try: os.unlink(self.iqtee_pipe)
|
|
||||||
# except: print "[openwebrx-dsp-plugin:csdr] stop() :: unlink failed: " + self.iqtee_pipe
|
|
||||||
# if self.iqtee2_pipe:
|
|
||||||
# try: os.unlink(self.iqtee2_pipe)
|
|
||||||
# except: print "[openwebrx-dsp-plugin:csdr] stop() :: unlink failed: " + self.iqtee2_pipe
|
|
||||||
|
|
||||||
self.running = False
|
|
||||||
|
|
||||||
def restart(self):
|
def restart(self):
|
||||||
|
if not self.running:
|
||||||
|
return
|
||||||
self.stop()
|
self.stop()
|
||||||
self.start()
|
self.start()
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
self.stop()
|
self.stop()
|
||||||
del(self.process)
|
del self.process
|
||||||
|
|||||||
6
docker/Dockerfiles/Dockerfile-airspy
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ARG ARCH
|
||||||
|
FROM openwebrx-base:$ARCH
|
||||||
|
|
||||||
|
ADD docker/scripts/install-dependencies-airspy.sh /
|
||||||
|
RUN /install-dependencies-airspy.sh
|
||||||
|
|
||||||
17
docker/Dockerfiles/Dockerfile-base
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
ARG BASE_IMAGE
|
||||||
|
FROM $BASE_IMAGE
|
||||||
|
|
||||||
|
RUN apk add --no-cache bash
|
||||||
|
|
||||||
|
ADD docker/scripts/direwolf-1.5.patch /
|
||||||
|
ADD docker/scripts/install-dependencies.sh /
|
||||||
|
RUN /install-dependencies.sh
|
||||||
|
|
||||||
|
ADD . /openwebrx
|
||||||
|
|
||||||
|
WORKDIR /openwebrx
|
||||||
|
|
||||||
|
VOLUME /config
|
||||||
|
|
||||||
|
ENTRYPOINT [ "/openwebrx/docker/scripts/run.sh" ]
|
||||||
|
EXPOSE 8073
|
||||||
11
docker/Dockerfiles/Dockerfile-full
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
ARG ARCH
|
||||||
|
FROM openwebrx-base:$ARCH
|
||||||
|
|
||||||
|
ADD docker/scripts/install-dependencies-*.sh /
|
||||||
|
ADD docker/scripts/install-lib.*.patch /
|
||||||
|
|
||||||
|
RUN /install-dependencies-rtlsdr.sh
|
||||||
|
RUN /install-dependencies-hackrf.sh
|
||||||
|
RUN /install-dependencies-soapysdr.sh
|
||||||
|
RUN /install-dependencies-sdrplay.sh
|
||||||
|
RUN /install-dependencies-airspy.sh
|
||||||
6
docker/Dockerfiles/Dockerfile-hackrf
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ARG ARCH
|
||||||
|
FROM openwebrx-base:$ARCH
|
||||||
|
|
||||||
|
ADD docker/scripts/install-dependencies-hackrf.sh /
|
||||||
|
RUN /install-dependencies-hackrf.sh
|
||||||
|
|
||||||
6
docker/Dockerfiles/Dockerfile-rtlsdr
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ARG ARCH
|
||||||
|
FROM openwebrx-base:$ARCH
|
||||||
|
|
||||||
|
ADD docker/scripts/install-dependencies-rtlsdr.sh /
|
||||||
|
RUN /install-dependencies-rtlsdr.sh
|
||||||
|
|
||||||
7
docker/Dockerfiles/Dockerfile-sdrplay
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
ARG ARCH
|
||||||
|
FROM openwebrx-soapysdr-base:$ARCH
|
||||||
|
|
||||||
|
ADD docker/scripts/install-dependencies-sdrplay.sh /
|
||||||
|
ADD docker/scripts/install-lib.*.patch /
|
||||||
|
RUN /install-dependencies-sdrplay.sh
|
||||||
|
|
||||||
6
docker/Dockerfiles/Dockerfile-soapysdr
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ARG ARCH
|
||||||
|
FROM openwebrx-base:$ARCH
|
||||||
|
|
||||||
|
ADD docker/scripts/install-dependencies-soapysdr.sh /
|
||||||
|
RUN /install-dependencies-soapysdr.sh
|
||||||
|
|
||||||
241
docker/scripts/direwolf-1.5.patch
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
diff --git a/Makefile.linux b/Makefile.linux
|
||||||
|
index 5010833..3f61de9 100644
|
||||||
|
--- a/Makefile.linux
|
||||||
|
+++ b/Makefile.linux
|
||||||
|
@@ -585,102 +585,102 @@ install : $(APPS) direwolf.conf tocalls.txt symbols-new.txt symbolsX.txt dw-icon
|
||||||
|
# Applications, not installed with package manager, normally go in /usr/local/bin.
|
||||||
|
# /usr/bin is used instead when installing from .DEB or .RPM package.
|
||||||
|
#
|
||||||
|
- $(INSTALL) -D --mode=755 direwolf $(DESTDIR)/bin/direwolf
|
||||||
|
- $(INSTALL) -D --mode=755 decode_aprs $(DESTDIR)/bin/decode_aprs
|
||||||
|
- $(INSTALL) -D --mode=755 text2tt $(DESTDIR)/bin/text2tt
|
||||||
|
- $(INSTALL) -D --mode=755 tt2text $(DESTDIR)/bin/tt2text
|
||||||
|
- $(INSTALL) -D --mode=755 ll2utm $(DESTDIR)/bin/ll2utm
|
||||||
|
- $(INSTALL) -D --mode=755 utm2ll $(DESTDIR)/bin/utm2ll
|
||||||
|
- $(INSTALL) -D --mode=755 aclients $(DESTDIR)/bin/aclients
|
||||||
|
- $(INSTALL) -D --mode=755 log2gpx $(DESTDIR)/bin/log2gpx
|
||||||
|
- $(INSTALL) -D --mode=755 gen_packets $(DESTDIR)/bin/gen_packets
|
||||||
|
- $(INSTALL) -D --mode=755 atest $(DESTDIR)/bin/atest
|
||||||
|
- $(INSTALL) -D --mode=755 ttcalc $(DESTDIR)/bin/ttcalc
|
||||||
|
- $(INSTALL) -D --mode=755 kissutil $(DESTDIR)/bin/kissutil
|
||||||
|
- $(INSTALL) -D --mode=755 cm108 $(DESTDIR)/bin/cm108
|
||||||
|
- $(INSTALL) -D --mode=755 dwespeak.sh $(DESTDIR)/bin/dwspeak.sh
|
||||||
|
+ $(INSTALL) -D -m=755 direwolf $(DESTDIR)/bin/direwolf
|
||||||
|
+ $(INSTALL) -D -m=755 decode_aprs $(DESTDIR)/bin/decode_aprs
|
||||||
|
+ $(INSTALL) -D -m=755 text2tt $(DESTDIR)/bin/text2tt
|
||||||
|
+ $(INSTALL) -D -m=755 tt2text $(DESTDIR)/bin/tt2text
|
||||||
|
+ $(INSTALL) -D -m=755 ll2utm $(DESTDIR)/bin/ll2utm
|
||||||
|
+ $(INSTALL) -D -m=755 utm2ll $(DESTDIR)/bin/utm2ll
|
||||||
|
+ $(INSTALL) -D -m=755 aclients $(DESTDIR)/bin/aclients
|
||||||
|
+ $(INSTALL) -D -m=755 log2gpx $(DESTDIR)/bin/log2gpx
|
||||||
|
+ $(INSTALL) -D -m=755 gen_packets $(DESTDIR)/bin/gen_packets
|
||||||
|
+ $(INSTALL) -D -m=755 atest $(DESTDIR)/bin/atest
|
||||||
|
+ $(INSTALL) -D -m=755 ttcalc $(DESTDIR)/bin/ttcalc
|
||||||
|
+ $(INSTALL) -D -m=755 kissutil $(DESTDIR)/bin/kissutil
|
||||||
|
+ $(INSTALL) -D -m=755 cm108 $(DESTDIR)/bin/cm108
|
||||||
|
+ $(INSTALL) -D -m=755 dwespeak.sh $(DESTDIR)/bin/dwspeak.sh
|
||||||
|
#
|
||||||
|
# Telemetry Toolkit executables. Other .conf and .txt files will go into doc directory.
|
||||||
|
#
|
||||||
|
- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-balloon.pl $(DESTDIR)/bin/telem-balloon.pl
|
||||||
|
- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-bits.pl $(DESTDIR)/bin/telem-bits.pl
|
||||||
|
- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-data.pl $(DESTDIR)/bin/telem-data.pl
|
||||||
|
- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-data91.pl $(DESTDIR)/bin/telem-data91.pl
|
||||||
|
- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-eqns.pl $(DESTDIR)/bin/telem-eqns.pl
|
||||||
|
- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-parm.pl $(DESTDIR)/bin/telem-parm.pl
|
||||||
|
- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-seq.sh $(DESTDIR)/bin/telem-seq.sh
|
||||||
|
- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-unit.pl $(DESTDIR)/bin/telem-unit.pl
|
||||||
|
- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-volts.py $(DESTDIR)/bin/telem-volts.py
|
||||||
|
+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-balloon.pl $(DESTDIR)/bin/telem-balloon.pl
|
||||||
|
+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-bits.pl $(DESTDIR)/bin/telem-bits.pl
|
||||||
|
+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-data.pl $(DESTDIR)/bin/telem-data.pl
|
||||||
|
+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-data91.pl $(DESTDIR)/bin/telem-data91.pl
|
||||||
|
+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-eqns.pl $(DESTDIR)/bin/telem-eqns.pl
|
||||||
|
+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-parm.pl $(DESTDIR)/bin/telem-parm.pl
|
||||||
|
+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-seq.sh $(DESTDIR)/bin/telem-seq.sh
|
||||||
|
+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-unit.pl $(DESTDIR)/bin/telem-unit.pl
|
||||||
|
+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-volts.py $(DESTDIR)/bin/telem-volts.py
|
||||||
|
#
|
||||||
|
# Misc. data such as "tocall" to system mapping.
|
||||||
|
#
|
||||||
|
- $(INSTALL) -D --mode=644 tocalls.txt $(DESTDIR)/share/direwolf/tocalls.txt
|
||||||
|
- $(INSTALL) -D --mode=644 symbols-new.txt $(DESTDIR)/share/direwolf/symbols-new.txt
|
||||||
|
- $(INSTALL) -D --mode=644 symbolsX.txt $(DESTDIR)/share/direwolf/symbolsX.txt
|
||||||
|
+ $(INSTALL) -D -m=644 tocalls.txt $(DESTDIR)/share/direwolf/tocalls.txt
|
||||||
|
+ $(INSTALL) -D -m=644 symbols-new.txt $(DESTDIR)/share/direwolf/symbols-new.txt
|
||||||
|
+ $(INSTALL) -D -m=644 symbolsX.txt $(DESTDIR)/share/direwolf/symbolsX.txt
|
||||||
|
#
|
||||||
|
# For desktop icon.
|
||||||
|
#
|
||||||
|
- $(INSTALL) -D --mode=644 dw-icon.png $(DESTDIR)/share/direwolf/pixmaps/dw-icon.png
|
||||||
|
- $(INSTALL) -D --mode=644 direwolf.desktop $(DESTDIR)/share/applications/direwolf.desktop
|
||||||
|
+ $(INSTALL) -D -m=644 dw-icon.png $(DESTDIR)/share/direwolf/pixmaps/dw-icon.png
|
||||||
|
+ $(INSTALL) -D -m=644 direwolf.desktop $(DESTDIR)/share/applications/direwolf.desktop
|
||||||
|
#
|
||||||
|
# Documentation. Various plain text files and PDF.
|
||||||
|
#
|
||||||
|
- $(INSTALL) -D --mode=644 CHANGES.md $(DESTDIR)/share/doc/direwolf/CHANGES.md
|
||||||
|
- $(INSTALL) -D --mode=644 LICENSE-dire-wolf.txt $(DESTDIR)/share/doc/direwolf/LICENSE-dire-wolf.txt
|
||||||
|
- $(INSTALL) -D --mode=644 LICENSE-other.txt $(DESTDIR)/share/doc/direwolf/LICENSE-other.txt
|
||||||
|
+ $(INSTALL) -D -m=644 CHANGES.md $(DESTDIR)/share/doc/direwolf/CHANGES.md
|
||||||
|
+ $(INSTALL) -D -m=644 LICENSE-dire-wolf.txt $(DESTDIR)/share/doc/direwolf/LICENSE-dire-wolf.txt
|
||||||
|
+ $(INSTALL) -D -m=644 LICENSE-other.txt $(DESTDIR)/share/doc/direwolf/LICENSE-other.txt
|
||||||
|
#
|
||||||
|
# ./README.md is an overview for the project main page.
|
||||||
|
# Maybe we could stick it in some other place.
|
||||||
|
# doc/README.md contains an overview of the PDF file contents and is more useful here.
|
||||||
|
#
|
||||||
|
- $(INSTALL) -D --mode=644 doc/README.md $(DESTDIR)/share/doc/direwolf/README.md
|
||||||
|
- $(INSTALL) -D --mode=644 doc/2400-4800-PSK-for-APRS-Packet-Radio.pdf $(DESTDIR)/share/doc/direwolf/2400-4800-PSK-for-APRS-Packet-Radio.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/A-Better-APRS-Packet-Demodulator-Part-1-1200-baud.pdf $(DESTDIR)/share/doc/direwolf/A-Better-APRS-Packet-Demodulator-Part-1-1200-baud.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/A-Better-APRS-Packet-Demodulator-Part-2-9600-baud.pdf $(DESTDIR)/share/doc/direwolf/A-Better-APRS-Packet-Demodulator-Part-2-9600-baud.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/A-Closer-Look-at-the-WA8LMF-TNC-Test-CD.pdf $(DESTDIR)/share/doc/direwolf/A-Closer-Look-at-the-WA8LMF-TNC-Test-CD.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/APRS-Telemetry-Toolkit.pdf $(DESTDIR)/share/doc/direwolf/APRS-Telemetry-Toolkit.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/APRStt-Implementation-Notes.pdf $(DESTDIR)/share/doc/direwolf/APRStt-Implementation-Notes.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/APRStt-interface-for-SARTrack.pdf $(DESTDIR)/share/doc/direwolf/APRStt-interface-for-SARTrack.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/APRStt-Listening-Example.pdf $(DESTDIR)/share/doc/direwolf/APRStt-Listening-Example.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/Bluetooth-KISS-TNC.pdf $(DESTDIR)/share/doc/direwolf/Bluetooth-KISS-TNC.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/Going-beyond-9600-baud.pdf $(DESTDIR)/share/doc/direwolf/Going-beyond-9600-baud.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/Raspberry-Pi-APRS.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-APRS.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/Raspberry-Pi-APRS-Tracker.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-APRS-Tracker.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/Raspberry-Pi-SDR-IGate.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-SDR-IGate.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/Successful-APRS-IGate-Operation.pdf $(DESTDIR)/share/doc/direwolf/Successful-APRS-IGate-Operation.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/User-Guide.pdf $(DESTDIR)/share/doc/direwolf/User-Guide.pdf
|
||||||
|
- $(INSTALL) -D --mode=644 doc/WA8LMF-TNC-Test-CD-Results.pdf $(DESTDIR)/share/doc/direwolf/WA8LMF-TNC-Test-CD-Results.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/README.md $(DESTDIR)/share/doc/direwolf/README.md
|
||||||
|
+ $(INSTALL) -D -m=644 doc/2400-4800-PSK-for-APRS-Packet-Radio.pdf $(DESTDIR)/share/doc/direwolf/2400-4800-PSK-for-APRS-Packet-Radio.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/A-Better-APRS-Packet-Demodulator-Part-1-1200-baud.pdf $(DESTDIR)/share/doc/direwolf/A-Better-APRS-Packet-Demodulator-Part-1-1200-baud.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/A-Better-APRS-Packet-Demodulator-Part-2-9600-baud.pdf $(DESTDIR)/share/doc/direwolf/A-Better-APRS-Packet-Demodulator-Part-2-9600-baud.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/A-Closer-Look-at-the-WA8LMF-TNC-Test-CD.pdf $(DESTDIR)/share/doc/direwolf/A-Closer-Look-at-the-WA8LMF-TNC-Test-CD.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/APRS-Telemetry-Toolkit.pdf $(DESTDIR)/share/doc/direwolf/APRS-Telemetry-Toolkit.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/APRStt-Implementation-Notes.pdf $(DESTDIR)/share/doc/direwolf/APRStt-Implementation-Notes.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/APRStt-interface-for-SARTrack.pdf $(DESTDIR)/share/doc/direwolf/APRStt-interface-for-SARTrack.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/APRStt-Listening-Example.pdf $(DESTDIR)/share/doc/direwolf/APRStt-Listening-Example.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/Bluetooth-KISS-TNC.pdf $(DESTDIR)/share/doc/direwolf/Bluetooth-KISS-TNC.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/Going-beyond-9600-baud.pdf $(DESTDIR)/share/doc/direwolf/Going-beyond-9600-baud.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/Raspberry-Pi-APRS.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-APRS.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/Raspberry-Pi-APRS-Tracker.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-APRS-Tracker.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/Raspberry-Pi-SDR-IGate.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-SDR-IGate.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/Successful-APRS-IGate-Operation.pdf $(DESTDIR)/share/doc/direwolf/Successful-APRS-IGate-Operation.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/User-Guide.pdf $(DESTDIR)/share/doc/direwolf/User-Guide.pdf
|
||||||
|
+ $(INSTALL) -D -m=644 doc/WA8LMF-TNC-Test-CD-Results.pdf $(DESTDIR)/share/doc/direwolf/WA8LMF-TNC-Test-CD-Results.pdf
|
||||||
|
#
|
||||||
|
# Various sample config and other files go into examples under the doc directory.
|
||||||
|
# When building from source, these can be put in home directory with "make install-conf".
|
||||||
|
# When installed from .DEB or .RPM package, the user will need to copy these to
|
||||||
|
# the home directory or other desired location.
|
||||||
|
#
|
||||||
|
- $(INSTALL) -D --mode=644 direwolf.conf $(DESTDIR)/share/doc/direwolf/examples/direwolf.conf
|
||||||
|
- $(INSTALL) -D --mode=755 dw-start.sh $(DESTDIR)/share/doc/direwolf/examples/dw-start.sh
|
||||||
|
- $(INSTALL) -D --mode=644 sdr.conf $(DESTDIR)/share/doc/direwolf/examples/sdr.conf
|
||||||
|
- $(INSTALL) -D --mode=644 telemetry-toolkit/telem-m0xer-3.txt $(DESTDIR)/share/doc/direwolf/examples/telem-m0xer-3.txt
|
||||||
|
- $(INSTALL) -D --mode=644 telemetry-toolkit/telem-balloon.conf $(DESTDIR)/share/doc/direwolf/examples/telem-balloon.conf
|
||||||
|
- $(INSTALL) -D --mode=644 telemetry-toolkit/telem-volts.conf $(DESTDIR)/share/doc/direwolf/examples/telem-volts.conf
|
||||||
|
+ $(INSTALL) -D -m=644 direwolf.conf $(DESTDIR)/share/doc/direwolf/examples/direwolf.conf
|
||||||
|
+ $(INSTALL) -D -m=755 dw-start.sh $(DESTDIR)/share/doc/direwolf/examples/dw-start.sh
|
||||||
|
+ $(INSTALL) -D -m=644 sdr.conf $(DESTDIR)/share/doc/direwolf/examples/sdr.conf
|
||||||
|
+ $(INSTALL) -D -m=644 telemetry-toolkit/telem-m0xer-3.txt $(DESTDIR)/share/doc/direwolf/examples/telem-m0xer-3.txt
|
||||||
|
+ $(INSTALL) -D -m=644 telemetry-toolkit/telem-balloon.conf $(DESTDIR)/share/doc/direwolf/examples/telem-balloon.conf
|
||||||
|
+ $(INSTALL) -D -m=644 telemetry-toolkit/telem-volts.conf $(DESTDIR)/share/doc/direwolf/examples/telem-volts.conf
|
||||||
|
#
|
||||||
|
# "man" pages
|
||||||
|
#
|
||||||
|
- $(INSTALL) -D --mode=644 man1/aclients.1 $(DESTDIR)/share/man/man1/aclients.1
|
||||||
|
- $(INSTALL) -D --mode=644 man1/atest.1 $(DESTDIR)/share/man/man1/atest.1
|
||||||
|
- $(INSTALL) -D --mode=644 man1/decode_aprs.1 $(DESTDIR)/share/man/man1/decode_aprs.1
|
||||||
|
- $(INSTALL) -D --mode=644 man1/direwolf.1 $(DESTDIR)/share/man/man1/direwolf.1
|
||||||
|
- $(INSTALL) -D --mode=644 man1/gen_packets.1 $(DESTDIR)/share/man/man1/gen_packets.1
|
||||||
|
- $(INSTALL) -D --mode=644 man1/kissutil.1 $(DESTDIR)/share/man/man1/kissutil.1
|
||||||
|
- $(INSTALL) -D --mode=644 man1/ll2utm.1 $(DESTDIR)/share/man/man1/ll2utm.1
|
||||||
|
- $(INSTALL) -D --mode=644 man1/log2gpx.1 $(DESTDIR)/share/man/man1/log2gpx.1
|
||||||
|
- $(INSTALL) -D --mode=644 man1/text2tt.1 $(DESTDIR)/share/man/man1/text2tt.1
|
||||||
|
- $(INSTALL) -D --mode=644 man1/tt2text.1 $(DESTDIR)/share/man/man1/tt2text.1
|
||||||
|
- $(INSTALL) -D --mode=644 man1/utm2ll.1 $(DESTDIR)/share/man/man1/utm2ll.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/aclients.1 $(DESTDIR)/share/man/man1/aclients.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/atest.1 $(DESTDIR)/share/man/man1/atest.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/decode_aprs.1 $(DESTDIR)/share/man/man1/decode_aprs.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/direwolf.1 $(DESTDIR)/share/man/man1/direwolf.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/gen_packets.1 $(DESTDIR)/share/man/man1/gen_packets.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/kissutil.1 $(DESTDIR)/share/man/man1/kissutil.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/ll2utm.1 $(DESTDIR)/share/man/man1/ll2utm.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/log2gpx.1 $(DESTDIR)/share/man/man1/log2gpx.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/text2tt.1 $(DESTDIR)/share/man/man1/text2tt.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/tt2text.1 $(DESTDIR)/share/man/man1/tt2text.1
|
||||||
|
+ $(INSTALL) -D -m=644 man1/utm2ll.1 $(DESTDIR)/share/man/man1/utm2ll.1
|
||||||
|
#
|
||||||
|
# Set group and mode of HID devices corresponding to C-Media USB Audio adapters.
|
||||||
|
# This will allow us to use the CM108/CM119 GPIO pins for PTT.
|
||||||
|
#
|
||||||
|
- $(INSTALL) -D --mode=644 99-direwolf-cmedia.rules /etc/udev/rules.d/99-direwolf-cmedia.rules
|
||||||
|
+ $(INSTALL) -D -m=644 99-direwolf-cmedia.rules /etc/udev/rules.d/99-direwolf-cmedia.rules
|
||||||
|
#
|
||||||
|
@echo " "
|
||||||
|
@echo "If this is your first install, not an upgrade, type this to put a copy"
|
||||||
|
diff --git a/cdigipeater.c b/cdigipeater.c
|
||||||
|
index 9c40d95..94112e9 100644
|
||||||
|
--- a/cdigipeater.c
|
||||||
|
+++ b/cdigipeater.c
|
||||||
|
@@ -49,7 +49,7 @@
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <ctype.h> /* for isdigit, isupper */
|
||||||
|
#include "regex.h"
|
||||||
|
-#include <sys/unistd.h>
|
||||||
|
+#include <unistd.h>
|
||||||
|
|
||||||
|
#include "ax25_pad.h"
|
||||||
|
#include "cdigipeater.h"
|
||||||
|
diff --git a/decode_aprs.c b/decode_aprs.c
|
||||||
|
index 35c186b..a620cb3 100644
|
||||||
|
--- a/decode_aprs.c
|
||||||
|
+++ b/decode_aprs.c
|
||||||
|
@@ -3872,11 +3872,7 @@ static void decode_tocall (decode_aprs_t *A, char *dest)
|
||||||
|
* models before getting to the more generic APY.
|
||||||
|
*/
|
||||||
|
|
||||||
|
-#if defined(__WIN32__) || defined(__OpenBSD__) || defined(__FreeBSD__) || defined(__APPLE__)
|
||||||
|
qsort (tocalls, num_tocalls, sizeof(struct tocalls_s), tocall_cmp);
|
||||||
|
-#else
|
||||||
|
- qsort (tocalls, num_tocalls, sizeof(struct tocalls_s), (__compar_fn_t)tocall_cmp);
|
||||||
|
-#endif
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if ( ! A->g_quiet) {
|
||||||
|
diff --git a/digipeater.c b/digipeater.c
|
||||||
|
index 36970d7..5195582 100644
|
||||||
|
--- a/digipeater.c
|
||||||
|
+++ b/digipeater.c
|
||||||
|
@@ -62,7 +62,7 @@
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <ctype.h> /* for isdigit, isupper */
|
||||||
|
#include "regex.h"
|
||||||
|
-#include <sys/unistd.h>
|
||||||
|
+#include <unistd.h>
|
||||||
|
|
||||||
|
#include "ax25_pad.h"
|
||||||
|
#include "digipeater.h"
|
||||||
|
diff --git a/direwolf.h b/direwolf.h
|
||||||
|
index 514bcc5..52f5ae9 100644
|
||||||
|
--- a/direwolf.h
|
||||||
|
+++ b/direwolf.h
|
||||||
|
@@ -274,7 +274,7 @@ char *strtok_r(char *str, const char *delim, char **saveptr);
|
||||||
|
char *strcasestr(const char *S, const char *FIND);
|
||||||
|
|
||||||
|
|
||||||
|
-#if defined(__OpenBSD__) || defined(__FreeBSD__) || defined(__APPLE__)
|
||||||
|
+#if 1
|
||||||
|
|
||||||
|
// strlcpy and strlcat should be in string.h and the C library.
|
||||||
|
|
||||||
|
diff --git a/multi_modem.c b/multi_modem.c
|
||||||
|
index 5d96c79..24261b9 100644
|
||||||
|
--- a/multi_modem.c
|
||||||
|
+++ b/multi_modem.c
|
||||||
|
@@ -80,7 +80,7 @@
|
||||||
|
#include <string.h>
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
-#include <sys/unistd.h>
|
||||||
|
+#include <unistd.h>
|
||||||
|
|
||||||
|
#include "ax25_pad.h"
|
||||||
|
#include "textcolor.h"
|
||||||
26
docker/scripts/install-dependencies-airspy.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
function cmakebuild() {
|
||||||
|
cd $1
|
||||||
|
mkdir build
|
||||||
|
cd build
|
||||||
|
cmake ..
|
||||||
|
make
|
||||||
|
make install
|
||||||
|
cd ../..
|
||||||
|
rm -rf $1
|
||||||
|
}
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
|
||||||
|
STATIC_PACKAGES="libusb"
|
||||||
|
BUILD_PACKAGES="git libusb-dev cmake make gcc musl-dev g++ linux-headers"
|
||||||
|
|
||||||
|
apk add --no-cache $STATIC_PACKAGES
|
||||||
|
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||||
|
|
||||||
|
git clone https://github.com/airspy/airspyone_host.git
|
||||||
|
cmakebuild airspyone_host
|
||||||
|
|
||||||
|
apk del .build-deps
|
||||||
29
docker/scripts/install-dependencies-hackrf.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
function cmakebuild() {
|
||||||
|
cd $1
|
||||||
|
mkdir build
|
||||||
|
cd build
|
||||||
|
cmake ..
|
||||||
|
make
|
||||||
|
make install
|
||||||
|
cd ../..
|
||||||
|
rm -rf $1
|
||||||
|
}
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
|
||||||
|
STATIC_PACKAGES="libusb fftw udev"
|
||||||
|
BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-dev fftw-dev"
|
||||||
|
|
||||||
|
apk add --no-cache $STATIC_PACKAGES
|
||||||
|
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||||
|
|
||||||
|
git clone https://github.com/mossmann/hackrf.git
|
||||||
|
cd hackrf
|
||||||
|
cmakebuild host
|
||||||
|
cd ..
|
||||||
|
rm -rf hackrf
|
||||||
|
|
||||||
|
apk del .build-deps
|
||||||
26
docker/scripts/install-dependencies-rtlsdr.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
function cmakebuild() {
|
||||||
|
cd $1
|
||||||
|
mkdir build
|
||||||
|
cd build
|
||||||
|
cmake ..
|
||||||
|
make
|
||||||
|
make install
|
||||||
|
cd ../..
|
||||||
|
rm -rf $1
|
||||||
|
}
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
|
||||||
|
STATIC_PACKAGES="libusb"
|
||||||
|
BUILD_PACKAGES="git libusb-dev cmake make gcc musl-dev g++ linux-headers"
|
||||||
|
|
||||||
|
apk add --no-cache $STATIC_PACKAGES
|
||||||
|
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||||
|
|
||||||
|
git clone https://github.com/osmocom/rtl-sdr.git
|
||||||
|
cmakebuild rtl-sdr
|
||||||
|
|
||||||
|
apk del .build-deps
|
||||||
47
docker/scripts/install-dependencies-sdrplay.sh
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
function cmakebuild() {
|
||||||
|
cd $1
|
||||||
|
mkdir build
|
||||||
|
cd build
|
||||||
|
cmake ..
|
||||||
|
make
|
||||||
|
make install
|
||||||
|
cd ../..
|
||||||
|
rm -rf $1
|
||||||
|
}
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
|
||||||
|
STATIC_PACKAGES="libusb udev"
|
||||||
|
BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-dev"
|
||||||
|
|
||||||
|
apk add --no-cache $STATIC_PACKAGES
|
||||||
|
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||||
|
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
|
||||||
|
case $ARCH in
|
||||||
|
x86_64)
|
||||||
|
BINARY=SDRplay_RSP_API-Linux-2.13.1.run
|
||||||
|
;;
|
||||||
|
armv*)
|
||||||
|
BINARY=SDRplay_RSP_API-RPi-2.13.1.run
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
wget http://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/SoapySDRPlay.git
|
||||||
|
cmakebuild SoapySDRPlay
|
||||||
|
|
||||||
|
apk del .build-deps
|
||||||
29
docker/scripts/install-dependencies-soapysdr.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
function cmakebuild() {
|
||||||
|
cd $1
|
||||||
|
mkdir build
|
||||||
|
cd build
|
||||||
|
cmake ..
|
||||||
|
make
|
||||||
|
make install
|
||||||
|
cd ../..
|
||||||
|
rm -rf $1
|
||||||
|
}
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
|
||||||
|
STATIC_PACKAGES="udev"
|
||||||
|
BUILD_PACKAGES="git cmake make patch wget sudo gcc g++"
|
||||||
|
|
||||||
|
apk add --no-cache $STATIC_PACKAGES
|
||||||
|
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||||
|
|
||||||
|
git clone https://github.com/pothosware/SoapySDR
|
||||||
|
cmakebuild SoapySDR
|
||||||
|
|
||||||
|
git clone https://github.com/rxseger/rx_tools
|
||||||
|
cmakebuild rx_tools
|
||||||
|
|
||||||
|
apk del .build-deps
|
||||||
63
docker/scripts/install-dependencies.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
function cmakebuild() {
|
||||||
|
cd $1
|
||||||
|
mkdir build
|
||||||
|
cd build
|
||||||
|
cmake ..
|
||||||
|
make
|
||||||
|
make install
|
||||||
|
cd ../..
|
||||||
|
rm -rf $1
|
||||||
|
}
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
|
||||||
|
STATIC_PACKAGES="sox fftw python3 netcat-openbsd libsndfile lapack libusb qt5-qtbase qt5-qtmultimedia qt5-qtserialport qt5-qttools alsa-lib"
|
||||||
|
BUILD_PACKAGES="git libsndfile-dev fftw-dev cmake ca-certificates make gcc musl-dev g++ lapack-dev linux-headers autoconf automake libtool texinfo gfortran libusb-dev qt5-qtbase-dev qt5-qtmultimedia-dev qt5-qtserialport-dev qt5-qttools-dev asciidoctor asciidoc alsa-lib-dev linux-headers"
|
||||||
|
|
||||||
|
apk add --no-cache $STATIC_PACKAGES
|
||||||
|
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||||
|
|
||||||
|
git clone https://git.code.sf.net/p/itpp/git itpp
|
||||||
|
cmakebuild itpp
|
||||||
|
|
||||||
|
git clone https://github.com/jketterl/csdr.git -b 48khz_filter
|
||||||
|
cd csdr
|
||||||
|
make
|
||||||
|
make install
|
||||||
|
cd ..
|
||||||
|
rm -rf csdr
|
||||||
|
|
||||||
|
git clone https://github.com/szechyjs/mbelib.git
|
||||||
|
cmakebuild mbelib
|
||||||
|
if [ -d "/usr/local/lib64" ]; then
|
||||||
|
# no idea why it's put into there now. alpine does not handle it correctly, so move it.
|
||||||
|
mv /usr/local/lib64/libmbe* /usr/local/lib
|
||||||
|
fi
|
||||||
|
|
||||||
|
git clone https://github.com/jketterl/digiham.git
|
||||||
|
cmakebuild digiham
|
||||||
|
|
||||||
|
git clone https://github.com/f4exb/dsd.git
|
||||||
|
cmakebuild dsd
|
||||||
|
|
||||||
|
WSJT_DIR=wsjtx-2.1.0
|
||||||
|
WSJT_TGZ=${WSJT_DIR}.tgz
|
||||||
|
wget http://physics.princeton.edu/pulsar/k1jt/$WSJT_TGZ
|
||||||
|
tar xvfz $WSJT_TGZ
|
||||||
|
cmakebuild $WSJT_DIR
|
||||||
|
|
||||||
|
git clone https://github.com/wb2osz/direwolf.git
|
||||||
|
cd direwolf
|
||||||
|
git checkout 1.5
|
||||||
|
patch -Np1 < /direwolf-1.5.patch
|
||||||
|
make
|
||||||
|
make install
|
||||||
|
cd ..
|
||||||
|
rm -rf direwolf
|
||||||
|
|
||||||
|
git clone https://github.com/hessu/aprs-symbols /opt/aprs-symbols
|
||||||
|
|
||||||
|
apk del .build-deps
|
||||||
40
docker/scripts/install-lib.armv7l.patch
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
--- sdrplay/install_lib.sh
|
||||||
|
+++ sdrplay/install_lib_patched.sh
|
||||||
|
@@ -3,19 +3,7 @@
|
||||||
|
|
||||||
|
echo "Installing SDRplay RSP API library 2.13..."
|
||||||
|
|
||||||
|
-more sdrplay_license.txt
|
||||||
|
-
|
||||||
|
-while true; do
|
||||||
|
- echo "Press y and RETURN to accept the license agreement and continue with"
|
||||||
|
- read -p "the installation, or press n and RETURN to exit the installer [y/n] " yn
|
||||||
|
- case $yn in
|
||||||
|
- [Yy]* ) break;;
|
||||||
|
- [Nn]* ) exit;;
|
||||||
|
- * ) echo "Please answer y or n";;
|
||||||
|
- esac
|
||||||
|
-done
|
||||||
|
-
|
||||||
|
-export ARCH=`arch`
|
||||||
|
+export ARCH=`uname -m`
|
||||||
|
export VERS="2.13"
|
||||||
|
|
||||||
|
echo "Architecture: ${ARCH}"
|
||||||
|
@@ -60,16 +48,6 @@
|
||||||
|
echo "ERROR: udev rules directory not found, add udev support and run the"
|
||||||
|
echo "installer again. udev support can be added by running..."
|
||||||
|
echo "sudo apt-get install libudev-dev"
|
||||||
|
- echo " "
|
||||||
|
- exit 1
|
||||||
|
-fi
|
||||||
|
-
|
||||||
|
-if /sbin/ldconfig -p | /bin/fgrep -q libusb-1.0; then
|
||||||
|
- echo "Libusb found, continuing..."
|
||||||
|
-else
|
||||||
|
- echo " "
|
||||||
|
- echo "ERROR: Libusb cannot be found. Please install libusb and then run"
|
||||||
|
- echo "the installer again. Libusb can be installed from http://libusb.info"
|
||||||
|
echo " "
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
40
docker/scripts/install-lib.x86_64.patch
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
--- sdrplay/install_lib.sh 2018-06-21 01:57:02.000000000 +0200
|
||||||
|
+++ sdrplay/install_lib_patched.sh 2019-01-22 17:21:06.445804136 +0100
|
||||||
|
@@ -2,19 +2,7 @@
|
||||||
|
|
||||||
|
echo "Installing SDRplay RSP API library 2.13..."
|
||||||
|
|
||||||
|
-more sdrplay_license.txt
|
||||||
|
-
|
||||||
|
-while true; do
|
||||||
|
- echo "Press y and RETURN to accept the license agreement and continue with"
|
||||||
|
- read -p "the installation, or press n and RETURN to exit the installer [y/n] " yn
|
||||||
|
- case $yn in
|
||||||
|
- [Yy]* ) break;;
|
||||||
|
- [Nn]* ) exit;;
|
||||||
|
- * ) echo "Please answer y or n";;
|
||||||
|
- esac
|
||||||
|
-done
|
||||||
|
-
|
||||||
|
-export ARCH=`arch`
|
||||||
|
+export ARCH=`uname -m`
|
||||||
|
export VERS="2.13"
|
||||||
|
|
||||||
|
echo "Architecture: ${ARCH}"
|
||||||
|
@@ -60,16 +48,6 @@
|
||||||
|
echo " "
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
-
|
||||||
|
-if /sbin/ldconfig -p | /bin/fgrep -q libusb-1.0; then
|
||||||
|
- echo "Libusb found, continuing..."
|
||||||
|
-else
|
||||||
|
- echo " "
|
||||||
|
- echo "ERROR: Libusb cannot be found. Please install libusb and then run"
|
||||||
|
- echo "the installer again. Libusb can be installed from http://libusb.info"
|
||||||
|
- echo " "
|
||||||
|
- exit 1
|
||||||
|
-fi
|
||||||
|
|
||||||
|
#echo "Installing SoapySDRPlay..."
|
||||||
|
|
||||||
23
docker/scripts/run.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ ! -f /config/config_webrx.py ]] ; then
|
||||||
|
cp config_webrx.py /config
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm config_webrx.py
|
||||||
|
ln -s /config/config_webrx.py .
|
||||||
|
|
||||||
|
|
||||||
|
_term() {
|
||||||
|
echo "Caught signal!"
|
||||||
|
kill -TERM "$child" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
trap _term SIGTERM SIGINT
|
||||||
|
|
||||||
|
python3 openwebrx.py $@ &
|
||||||
|
|
||||||
|
child=$!
|
||||||
|
wait "$child"
|
||||||
|
|
||||||
12
htdocs/css/features.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
@import url("openwebrx-header.css");
|
||||||
|
@import url("openwebrx-globals.css");
|
||||||
|
|
||||||
|
/* expandable photo not implemented on features page */
|
||||||
|
#webrx-top-photo-clip {
|
||||||
|
max-height: 67px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin: 50px 0;
|
||||||
|
}
|
||||||
57
htdocs/css/map.css
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
@import url("openwebrx-header.css");
|
||||||
|
@import url("openwebrx-globals.css");
|
||||||
|
|
||||||
|
/* expandable photo not implemented on map page */
|
||||||
|
#webrx-top-photo-clip {
|
||||||
|
max-height: 67px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-top-container {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-map-legend {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-map-legend ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
8
htdocs/css/openwebrx-globals.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
html, body
|
||||||
|
{
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
203
htdocs/css/openwebrx-header.css
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
#webrx-top-container
|
||||||
|
{
|
||||||
|
position: relative;
|
||||||
|
z-index:1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-top-photo
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-top-photo-clip
|
||||||
|
{
|
||||||
|
min-height: 67px;
|
||||||
|
max-height: 350px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webrx-top-bar-parts
|
||||||
|
{
|
||||||
|
height:67px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-top-bar
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-top-logo
|
||||||
|
{
|
||||||
|
padding: 12px;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-ha5kfu-top-logo
|
||||||
|
{
|
||||||
|
float: right;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-rx-avatar-background
|
||||||
|
{
|
||||||
|
cursor:pointer;
|
||||||
|
background-image: url(../gfx/openwebrx-avatar-background.png);
|
||||||
|
background-origin: content-box;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
float: left;
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
padding: 7px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-rx-avatar
|
||||||
|
{
|
||||||
|
cursor:pointer;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-rx-texts {
|
||||||
|
float: left;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-rx-texts div {
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-rx-title
|
||||||
|
{
|
||||||
|
white-space:nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor:pointer;
|
||||||
|
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
|
||||||
|
color: #909090;
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-rx-desc
|
||||||
|
{
|
||||||
|
white-space:nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor:pointer;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #909090;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-rx-desc a
|
||||||
|
{
|
||||||
|
color: #909090;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-rx-details-arrow
|
||||||
|
{
|
||||||
|
cursor:pointer;
|
||||||
|
position: absolute;
|
||||||
|
left: 470px;
|
||||||
|
top: 51px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-rx-details-arrow a
|
||||||
|
{
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-rx-details-arrow-down
|
||||||
|
{
|
||||||
|
display:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-main-buttons ul
|
||||||
|
{
|
||||||
|
display: table;
|
||||||
|
margin:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#openwebrx-main-buttons ul li
|
||||||
|
{
|
||||||
|
display: table-cell;
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-main-buttons a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-main-buttons li:hover
|
||||||
|
{
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-main-buttons li:active
|
||||||
|
{
|
||||||
|
background-color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#openwebrx-main-buttons
|
||||||
|
{
|
||||||
|
float: right;
|
||||||
|
margin:0;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0px 0px 4px #000000;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-rx-photo-title
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 78px;
|
||||||
|
color: White;
|
||||||
|
font-size: 16pt;
|
||||||
|
text-shadow: 1px 1px 4px #444;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#webrx-rx-photo-desc
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 109px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,13 +18,10 @@
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
@import url("openwebrx-header.css");
|
||||||
|
@import url("openwebrx-globals.css");
|
||||||
|
|
||||||
html, body
|
html, body {
|
||||||
{
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
height: 100%;
|
|
||||||
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,247 +144,115 @@ input[type=range]:focus::-ms-fill-upper
|
|||||||
background: #B6B6B6;
|
background: #B6B6B6;
|
||||||
}
|
}
|
||||||
|
|
||||||
#webrx-top-container
|
|
||||||
{
|
|
||||||
position: relative;
|
|
||||||
z-index:1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webrx-top-bar-parts
|
|
||||||
{
|
|
||||||
position: absolute;
|
|
||||||
top: 0px;
|
|
||||||
left: 0px;
|
|
||||||
width:100%;
|
|
||||||
height:67px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-top-bar-background
|
|
||||||
{
|
|
||||||
background-color: #808080;
|
|
||||||
opacity: 0.15;
|
|
||||||
filter:alpha(opacity=15);
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-top-bar
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-top-logo
|
|
||||||
{
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
left: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-ha5kfu-top-logo
|
|
||||||
{
|
|
||||||
position: absolute;
|
|
||||||
top: 15px;
|
|
||||||
right: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-top-photo
|
|
||||||
{
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-rx-avatar-background
|
|
||||||
{
|
|
||||||
cursor:pointer;
|
|
||||||
position: absolute;
|
|
||||||
left: 285px;
|
|
||||||
top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-rx-avatar
|
|
||||||
{
|
|
||||||
cursor:pointer;
|
|
||||||
position: absolute;
|
|
||||||
left: 289px;
|
|
||||||
top: 10px;
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-top-photo-clip
|
|
||||||
{
|
|
||||||
max-height: 350px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*#webrx-bottom-bar
|
|
||||||
{
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0px;
|
|
||||||
width: 100%;
|
|
||||||
height: 117px;
|
|
||||||
background-image:url(gfx/webrx-bottom-bar.png);
|
|
||||||
}*/
|
|
||||||
|
|
||||||
#webrx-page-container
|
#webrx-page-container
|
||||||
{
|
{
|
||||||
min-height:100%;
|
min-height:100%;
|
||||||
position:relative;
|
position:relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*#webrx-photo-gradient-left
|
|
||||||
{
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0px;
|
|
||||||
left: 0px;
|
|
||||||
background-image:url(gfx/webrx-photo-gradient-corner.png);
|
|
||||||
width: 59px;
|
|
||||||
height: 92px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-photo-gradient-middle
|
|
||||||
{
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0px;
|
|
||||||
left: 59px;
|
|
||||||
right: 59px;
|
|
||||||
height: 92px;
|
|
||||||
background-image:url(gfx/webrx-photo-gradient-middle.png);
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-photo-gradient-right
|
|
||||||
{
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0px;
|
|
||||||
right: 0px;
|
|
||||||
background-image:url(gfx/webrx-photo-gradient-corner.png);
|
|
||||||
width: 59px;
|
|
||||||
height: 92px;
|
|
||||||
-webkit-transform:scaleX(-1);
|
|
||||||
-moz-transform:scaleX(-1);
|
|
||||||
-ms-transform:scaleX(-1);
|
|
||||||
-o-transform:scaleX(-1);
|
|
||||||
transform:scaleX(-1);
|
|
||||||
}*/
|
|
||||||
|
|
||||||
#webrx-rx-photo-title
|
|
||||||
{
|
|
||||||
position: absolute;
|
|
||||||
left: 15px;
|
|
||||||
top: 78px;
|
|
||||||
color: White;
|
|
||||||
font-size: 16pt;
|
|
||||||
text-shadow: 1px 1px 4px #444;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-rx-photo-desc
|
|
||||||
{
|
|
||||||
position: absolute;
|
|
||||||
left: 15px;
|
|
||||||
top: 109px;
|
|
||||||
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: #007df1;*/
|
|
||||||
color: #5ca8ff;
|
|
||||||
text-shadow: none;
|
|
||||||
/*text-shadow: 0px 0px 7px #fff;*/
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-rx-title
|
|
||||||
{
|
|
||||||
white-space:nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor:pointer;
|
|
||||||
position: absolute;
|
|
||||||
left: 350px;
|
|
||||||
top: 13px;
|
|
||||||
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
|
|
||||||
color: #909090;
|
|
||||||
font-size: 11pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-rx-desc
|
|
||||||
{
|
|
||||||
white-space:nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor:pointer;
|
|
||||||
font-size: 10pt;
|
|
||||||
color: #909090;
|
|
||||||
position: absolute;
|
|
||||||
left: 350px;
|
|
||||||
top: 34px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#webrx-rx-desc a
|
|
||||||
{
|
|
||||||
color: #909090;
|
|
||||||
/*text-decoration: none;*/
|
|
||||||
}
|
|
||||||
|
|
||||||
#openwebrx-rx-details-arrow
|
|
||||||
{
|
|
||||||
cursor:pointer;
|
|
||||||
position: absolute;
|
|
||||||
left: 470px;
|
|
||||||
top: 51px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#openwebrx-rx-details-arrow a
|
|
||||||
{
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#openwebrx-rx-details-arrow-down
|
|
||||||
{
|
|
||||||
display:none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*canvas#waterfall-canvas
|
|
||||||
{
|
|
||||||
border-style: none;
|
|
||||||
border-width: 1px;
|
|
||||||
height: 150px;
|
|
||||||
width: 100%;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
#openwebrx-scale-container
|
#openwebrx-scale-container
|
||||||
{
|
{
|
||||||
height: 47px;
|
height: 47px;
|
||||||
background-image: url("gfx/openwebrx-scale-background.png");
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index:1000;
|
z-index:1000;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#openwebrx-frequency-container {
|
||||||
|
background-image: url("../gfx/openwebrx-scale-background.png");
|
||||||
|
background-repeat: repeat-x;
|
||||||
|
background-size: cover;
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
#webrx-canvas-container
|
#webrx-canvas-container
|
||||||
{
|
{
|
||||||
/*background-image:url('gfx/openwebrx-blank-background-1.jpg');*/
|
/*background-image:url('../gfx/openwebrx-blank-background-1.jpg');*/
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 2000px;
|
height: 2000px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
/*background-color: #646464;*/
|
/*background-color: #646464;*/
|
||||||
/*background-image: -webkit-linear-gradient(top, rgba(247,247,247,1) 0%, rgba(0,0,0,1) 100%);*/
|
/*background-image: -webkit-linear-gradient(top, rgba(247,247,247,1) 0%, rgba(0,0,0,1) 100%);*/
|
||||||
background-image: url('gfx/openwebrx-background-cool-blue.png');
|
background-image: url('../gfx/openwebrx-background-cool-blue.png');
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-color: #1e5f7f;
|
background-color: #1e5f7f;
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
@@ -477,15 +342,15 @@ input[type=range]:focus::-ms-fill-upper
|
|||||||
/* removed non-free fonts like that: */
|
/* removed non-free fonts like that: */
|
||||||
/*@font-face {
|
/*@font-face {
|
||||||
font-family: 'unibody_8_pro_regregular';
|
font-family: 'unibody_8_pro_regregular';
|
||||||
src: url('gfx/unibody8pro-regular-webfont.eot');
|
src: url('../gfx/unibody8pro-regular-webfont.eot');
|
||||||
src: url('gfx/unibody8pro-regular-webfont.ttf');
|
src: url('../gfx/unibody8pro-regular-webfont.ttf');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'expletus-sans-medium';
|
font-family: 'expletus-sans-medium';
|
||||||
src: url('gfx/font-expletus-sans/ExpletusSans-Medium.ttf');
|
src: url('../gfx/font-expletus-sans/ExpletusSans-Medium.ttf');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
@@ -582,6 +447,20 @@ input[type=range]:focus::-ms-fill-upper
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openwebrx-dial-button svg {
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-dial-button #ph_dial {
|
||||||
|
fill: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-dial-button.available #ph_dial {
|
||||||
|
fill: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
.openwebrx-square-button img
|
.openwebrx-square-button img
|
||||||
{
|
{
|
||||||
height: 27px;
|
height: 27px;
|
||||||
@@ -686,54 +565,27 @@ img.openwebrx-mirror-img
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#openwebrx-main-buttons img
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
#openwebrx-main-buttons ul
|
|
||||||
{
|
|
||||||
display: table;
|
|
||||||
margin:0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#openwebrx-main-buttons ul li
|
|
||||||
{
|
|
||||||
display: table-cell;
|
|
||||||
padding-left: 5px;
|
|
||||||
padding-right: 5px;
|
|
||||||
cursor:pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#openwebrx-main-buttons li:hover
|
|
||||||
{
|
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#openwebrx-main-buttons li:active
|
|
||||||
{
|
|
||||||
background-color: rgba(255, 255, 255, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#openwebrx-main-buttons
|
|
||||||
{
|
|
||||||
position: absolute;
|
|
||||||
right: 133px;
|
|
||||||
top: 3px;
|
|
||||||
margin:0;
|
|
||||||
color: white;
|
|
||||||
text-shadow: 0px 0px 4px #000000;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 9pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#openwebrx-panel-receiver
|
#openwebrx-panel-receiver
|
||||||
{
|
{
|
||||||
width:110px;
|
width:110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#openwebrx-panel-receiver .frequencies-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-panel-receiver .frequencies {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-panel-receiver .openwebrx-bookmark-button {
|
||||||
|
width: 27px;
|
||||||
|
height: 27px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
#openwebrx-mute-on
|
#openwebrx-mute-on
|
||||||
{
|
{
|
||||||
color: lime;
|
color: lime;
|
||||||
@@ -763,6 +615,10 @@ img.openwebrx-mirror-img
|
|||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openwebrx-panel-line:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#openwebrx-smeter-outer
|
#openwebrx-smeter-outer
|
||||||
{
|
{
|
||||||
border-color: #888;
|
border-color: #888;
|
||||||
@@ -841,10 +697,9 @@ img.openwebrx-mirror-img
|
|||||||
transition: width 500ms, left 500ms;
|
transition: width 500ms, left 500ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
#openwebrx-secondary-demod-listbox
|
.openwebrx-panel select,
|
||||||
{
|
.openwebrx-dialog select,
|
||||||
width: 201px;
|
.openwebrx-dialog input {
|
||||||
height: 27px;
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background-color: #373737;
|
background-color: #373737;
|
||||||
color: White;
|
color: White;
|
||||||
@@ -856,16 +711,28 @@ img.openwebrx-mirror-img
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
border-width: 0px;
|
border-width: 0px;
|
||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
padding-left:3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#openwebrx-secondary-demod-listbox option
|
.openwebrx-panel select option,
|
||||||
{
|
.openwebrx-dialog select option {
|
||||||
border-width: 0px;
|
border-width: 0px;
|
||||||
background-color: #373737;
|
background-color: #373737;
|
||||||
color: White;
|
color: White;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#openwebrx-secondary-demod-listbox
|
||||||
|
{
|
||||||
|
width: 173px;
|
||||||
|
height: 27px;
|
||||||
|
padding-left:3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-sdr-profiles-listbox {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 10pt;
|
||||||
|
height: 27px;
|
||||||
|
}
|
||||||
|
|
||||||
#openwebrx-cursor-blink
|
#openwebrx-cursor-blink
|
||||||
{
|
{
|
||||||
animation: cursor-blink 1s infinite;
|
animation: cursor-blink 1s infinite;
|
||||||
@@ -931,17 +798,12 @@ img.openwebrx-mirror-img
|
|||||||
#openwebrx-digimode-content .part
|
#openwebrx-digimode-content .part
|
||||||
{
|
{
|
||||||
animation: new-digimode-data-3d 100ms;
|
animation: new-digimode-data-3d 100ms;
|
||||||
animation-timing-function: linear;
|
animation-timing-function: linear;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
perspective-origin: 50% 50%;
|
perspective-origin: 50% 50%;
|
||||||
transform-origin: 0% 50%;
|
transform-origin: 0% 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#openwebrx-digimode-content .part .subpart
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@keyframes new-digimode-data
|
@keyframes new-digimode-data
|
||||||
{
|
{
|
||||||
0%{ opacity: 0; }
|
0%{ opacity: 0; }
|
||||||
@@ -971,3 +833,246 @@ img.openwebrx-mirror-img
|
|||||||
border-color: Red;
|
border-color: Red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openwebrx-meta-slot {
|
||||||
|
width: 145px;
|
||||||
|
height: 196px;
|
||||||
|
float: left;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
background-color: #676767;
|
||||||
|
padding: 2px 0;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-meta-slot, .openwebrx-meta-slot.muted:before {
|
||||||
|
-webkit-border-radius: 5px;
|
||||||
|
-moz-border-radius: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-meta-slot.muted:before {
|
||||||
|
display: block;
|
||||||
|
content: "";
|
||||||
|
background-image: url("../gfx/openwebrx-mute.png");
|
||||||
|
width:100%;
|
||||||
|
height:133px;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: rgba(0,0,0,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-meta-slot .openwebrx-meta-user-image {
|
||||||
|
width:100%;
|
||||||
|
height:133px;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-meta-slot.active .openwebrx-meta-user-image {
|
||||||
|
background-image: url("../gfx/openwebrx-directcall.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-meta-slot.active .openwebrx-meta-user-image.group {
|
||||||
|
background-image: url("../gfx/openwebrx-groupcall.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-dmr-timeslot-panel * {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-maps-pin {
|
||||||
|
background-image: url("../gfx/google_maps_pin.svg");
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
background-size: contain;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-panel-wsjt-message,
|
||||||
|
#openwebrx-panel-packet-message
|
||||||
|
{
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-panel-wsjt-message tbody,
|
||||||
|
#openwebrx-panel-packet-message tbody
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
height: 150px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-panel-wsjt-message thead tr,
|
||||||
|
#openwebrx-panel-packet-message thead tr
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-panel-wsjt-message th,
|
||||||
|
#openwebrx-panel-wsjt-message td,
|
||||||
|
#openwebrx-panel-packet-message th,
|
||||||
|
#openwebrx-panel-packet-message 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-packet-message .message {
|
||||||
|
width: 410px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-panel-packet-message .callsign {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#openwebrx-panel-packet-message .coord {
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 1 0 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openwebrx-dialog .form-field input,
|
||||||
|
.openwebrx-dialog .form-field select {
|
||||||
|
flex: 2 0 20px;
|
||||||
|
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="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
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
height: 200px;
|
||||||
|
margin: -10px;
|
||||||
|
}
|
||||||
21
htdocs/features.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<HTML><HEAD>
|
||||||
|
<TITLE>OpenWebRX Feature report</TITLE>
|
||||||
|
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="static/css/features.css">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.0/showdown.min.js"></script>
|
||||||
|
<script src="static/lib/jquery-3.2.1.min.js"></script>
|
||||||
|
<script src="static/features.js"></script>
|
||||||
|
</HEAD><BODY>
|
||||||
|
${header}
|
||||||
|
<div class="container">
|
||||||
|
<h1>OpenWebRX Feature Report</h1>
|
||||||
|
<table class="features table">
|
||||||
|
<tr>
|
||||||
|
<th>Feature</th>
|
||||||
|
<th>Requirement</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Available</th>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</BODY></HTML>
|
||||||
24
htdocs/features.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
$(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 '<tr>' +
|
||||||
|
'<td></td>' +
|
||||||
|
'<td>' + name + '</td>' +
|
||||||
|
'<td>' + converter.makeHtml(r.description) + '</td>' +
|
||||||
|
'<td>' + (r.available ? 'YES' : 'NO') + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
$table.append(
|
||||||
|
'<tr>' +
|
||||||
|
'<td colspan=2>' + name + '</td>' +
|
||||||
|
'<td>' + converter.makeHtml(details.description) + '</td>' +
|
||||||
|
'<td>' + (details.available ? 'YES' : 'NO') + '</td>' +
|
||||||
|
'</tr>' +
|
||||||
|
requirements.join("")
|
||||||
|
);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
77
htdocs/gfx/google_maps_pin.svg
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="5.6444445mm"
|
||||||
|
height="9.847393mm"
|
||||||
|
viewBox="0 0 20 34.892337"
|
||||||
|
id="svg3455"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.91 r13725"
|
||||||
|
sodipodi:docname="Map Pin.svg">
|
||||||
|
<defs
|
||||||
|
id="defs3457" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="12.181359"
|
||||||
|
inkscape:cx="8.4346812"
|
||||||
|
inkscape:cy="14.715224"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:window-width="1024"
|
||||||
|
inkscape:window-height="705"
|
||||||
|
inkscape:window-x="-4"
|
||||||
|
inkscape:window-y="-4"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
fit-margin-top="0"
|
||||||
|
fit-margin-left="0"
|
||||||
|
fit-margin-right="0"
|
||||||
|
fit-margin-bottom="0" />
|
||||||
|
<metadata
|
||||||
|
id="metadata3460">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-814.59595,-274.38623)">
|
||||||
|
<g
|
||||||
|
id="g3477"
|
||||||
|
transform="matrix(1.1855854,0,0,1.1855854,-151.17715,-57.3976)">
|
||||||
|
<path
|
||||||
|
sodipodi:nodetypes="sscccccsscs"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path4337-3"
|
||||||
|
d="m 817.11249,282.97118 c -1.25816,1.34277 -2.04623,3.29881 -2.01563,5.13867 0.0639,3.84476 1.79693,5.3002 4.56836,10.59179 0.99832,2.32851 2.04027,4.79237 3.03125,8.87305 0.13772,0.60193 0.27203,1.16104 0.33416,1.20948 0.0621,0.0485 0.19644,-0.51262 0.33416,-1.11455 0.99098,-4.08068 2.03293,-6.54258 3.03125,-8.87109 2.77143,-5.29159 4.50444,-6.74704 4.56836,-10.5918 0.0306,-1.83986 -0.75942,-3.79785 -2.01758,-5.14062 -1.43724,-1.53389 -3.60504,-2.66908 -5.91619,-2.71655 -2.31115,-0.0475 -4.4809,1.08773 -5.91814,2.62162 z"
|
||||||
|
style="display:inline;opacity:1;fill:#ff4646;fill-opacity:1;stroke:#d73534;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||||
|
<circle
|
||||||
|
r="3.0355"
|
||||||
|
cy="288.25278"
|
||||||
|
cx="823.03064"
|
||||||
|
id="path3049"
|
||||||
|
style="display:inline;opacity:1;fill:#590000;fill-opacity:1;stroke-width:0" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 13 KiB |
BIN
htdocs/gfx/openwebrx-bookmark.png
Normal file
|
After Width: | Height: | Size: 970 B |
BIN
htdocs/gfx/openwebrx-directcall.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
htdocs/gfx/openwebrx-edit.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
htdocs/gfx/openwebrx-groupcall.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
htdocs/gfx/openwebrx-mute.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
htdocs/gfx/openwebrx-panel-map.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 69 KiB |
BIN
htdocs/gfx/openwebrx-trashcan.png
Normal file
|
After Width: | Height: | Size: 797 B |
@@ -1,85 +0,0 @@
|
|||||||
<html>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
This file is part of OpenWebRX,
|
|
||||||
an open-source SDR receiver software with a web UI.
|
|
||||||
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
|
|
||||||
|
|
||||||
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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<head><title>OpenWebRX</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
||||||
<style>
|
|
||||||
html, body
|
|
||||||
{
|
|
||||||
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
img.logo
|
|
||||||
{
|
|
||||||
margin-top: 120px;
|
|
||||||
}
|
|
||||||
div.frame
|
|
||||||
{
|
|
||||||
text-align: left;
|
|
||||||
margin:0px auto;
|
|
||||||
width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.panel
|
|
||||||
{
|
|
||||||
text-align: center;
|
|
||||||
background-color:#777777;
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: White;
|
|
||||||
font-size: 13pt;
|
|
||||||
/*text-shadow: 1px 1px 4px #444;*/
|
|
||||||
font-family: sans;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.alt
|
|
||||||
{
|
|
||||||
font-size: 10pt;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
body div a
|
|
||||||
{
|
|
||||||
color: #5ca8ff;
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.browser
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="frame">
|
|
||||||
<img class="logo" src="gfx/openwebrx-logo-big.png" style="height: 60px;"/>
|
|
||||||
<div class="panel">
|
|
||||||
Sorry, the receiver is inactive due to internal error.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
30
htdocs/include/header.include.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<div id="webrx-top-container">
|
||||||
|
<div id="webrx-top-photo-clip">
|
||||||
|
<img src="static/gfx/openwebrx-top-photo.jpg" id="webrx-top-photo"/>
|
||||||
|
<div id="webrx-top-bar" class="webrx-top-bar-parts">
|
||||||
|
<a href="https://sdr.hu/openwebrx" target="_blank"><img src="static/gfx/openwebrx-top-logo.png" id="webrx-top-logo" /></a>
|
||||||
|
<a href="http://ha5kfu.sch.bme.hu/" target="_blank"><img src="static/gfx/openwebrx-ha5kfu-top-logo.png" id="webrx-ha5kfu-top-logo" /></a>
|
||||||
|
<div id="webrx-rx-avatar-background">
|
||||||
|
<img id="webrx-rx-avatar" class="openwebrx-photo-trigger" src="static/gfx/openwebrx-avatar.png"/>
|
||||||
|
</div>
|
||||||
|
<div id="webrx-rx-texts">
|
||||||
|
<div id="webrx-rx-title" class="openwebrx-photo-trigger"></div>
|
||||||
|
<div id="webrx-rx-desc" class="openwebrx-photo-trigger"></div>
|
||||||
|
</div>
|
||||||
|
<div id="openwebrx-rx-details-arrow">
|
||||||
|
<a id="openwebrx-rx-details-arrow-up" class="openwebrx-photo-trigger"><img src="static/gfx/openwebrx-rx-details-arrow-up.png" /></a>
|
||||||
|
<a id="openwebrx-rx-details-arrow-down" class="openwebrx-photo-trigger"><img src="static/gfx/openwebrx-rx-details-arrow.png" /></a>
|
||||||
|
</div>
|
||||||
|
<section id="openwebrx-main-buttons">
|
||||||
|
<ul>
|
||||||
|
<li data-toggle-panel="openwebrx-panel-status"><img src="static/gfx/openwebrx-panel-status.png" /><br/>Status</li>
|
||||||
|
<li data-toggle-panel="openwebrx-panel-log"><img src="static/gfx/openwebrx-panel-log.png" /><br/>Log</li>
|
||||||
|
<li data-toggle-panel="openwebrx-panel-receiver"><img src="static/gfx/openwebrx-panel-receiver.png" /><br/>Receiver</li>
|
||||||
|
<li><a href="/map" target="_blank"><img src="static/gfx/openwebrx-panel-map.png" /><br/>Map</a></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div id="webrx-rx-photo-title"></div>
|
||||||
|
<div id="webrx-rx-photo-desc"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
255
htdocs/index.html
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<!DOCTYPE HTML>
|
||||||
|
<!--
|
||||||
|
|
||||||
|
This file is part of OpenWebRX,
|
||||||
|
an open-source SDR receiver software with a web UI.
|
||||||
|
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
|
||||||
|
|
||||||
|
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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenWebRX | Open Source SDR Web App for Everyone!</title>
|
||||||
|
<script src="static/sdr.js"></script>
|
||||||
|
<script src="static/mathbox-bundle.min.js"></script>
|
||||||
|
<script src="static/openwebrx.js"></script>
|
||||||
|
<script src="static/lib/jquery-3.2.1.min.js"></script>
|
||||||
|
<script src="static/lib/jquery.nanoscroller.js"></script>
|
||||||
|
<link rel="stylesheet" type="text/css" href="static/lib/nanoscroller.css" />
|
||||||
|
<link rel="stylesheet" type="text/css" href="static/css/openwebrx.css" />
|
||||||
|
<meta charset="utf-8">
|
||||||
|
</head>
|
||||||
|
<body onload="openwebrx_init();">
|
||||||
|
<div id="webrx-page-container">
|
||||||
|
${header}
|
||||||
|
<div id="webrx-main-container">
|
||||||
|
<div id="openwebrx-frequency-container">
|
||||||
|
<div id="openwebrx-bookmarks-container"></div>
|
||||||
|
<div id="openwebrx-scale-container">
|
||||||
|
<canvas id="openwebrx-scale-canvas" width="0" height="0"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="openwebrx-mathbox-container"> </div>
|
||||||
|
<div id="webrx-canvas-container">
|
||||||
|
<div id="openwebrx-phantom-canvas"></div>
|
||||||
|
<!-- add canvas here by javascript -->
|
||||||
|
</div>
|
||||||
|
<div id="openwebrx-panels-container">
|
||||||
|
<div class="openwebrx-panel" id="openwebrx-panel-receiver" data-panel-name="client-params" data-panel-pos="right" data-panel-order="0" data-panel-size="259,115">
|
||||||
|
<div class="openwebrx-panel-line frequencies-container">
|
||||||
|
<div class="frequencies">
|
||||||
|
<div id="webrx-actual-freq">---.--- MHz</div>
|
||||||
|
<div id="webrx-mouse-freq">---.--- MHz</div>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-button openwebrx-square-button openwebrx-bookmark-button" style="display:none;">
|
||||||
|
<img src="static/gfx/openwebrx-bookmark.png">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel-line">
|
||||||
|
<select id="openwebrx-sdr-profiles-listbox" onchange="sdr_profile_changed();">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel-line">
|
||||||
|
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-nfm"
|
||||||
|
onclick="demodulator_analog_replace('nfm');">FM</div>
|
||||||
|
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-am"
|
||||||
|
onclick="demodulator_analog_replace('am');">AM</div>
|
||||||
|
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-lsb"
|
||||||
|
onclick="demodulator_analog_replace('lsb');">LSB</div>
|
||||||
|
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-usb"
|
||||||
|
onclick="demodulator_analog_replace('usb');">USB</div>
|
||||||
|
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-cw"
|
||||||
|
onclick="demodulator_analog_replace('cw');">CW</div>
|
||||||
|
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-dmr"
|
||||||
|
style="display:none;" data-feature="digital_voice_digiham"
|
||||||
|
onclick="demodulator_analog_replace('dmr');">DMR</div>
|
||||||
|
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-dstar"
|
||||||
|
style="display:none;" data-feature="digital_voice_dsd"
|
||||||
|
onclick="demodulator_analog_replace('dstar');">DStar</div>
|
||||||
|
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-nxdn"
|
||||||
|
style="display:none;" data-feature="digital_voice_dsd"
|
||||||
|
onclick="demodulator_analog_replace('nxdn');">NXDN</div>
|
||||||
|
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-ysf"
|
||||||
|
style="display:none;" data-feature="digital_voice_digiham"
|
||||||
|
onclick="demodulator_analog_replace('ysf');">YSF</div>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel-line">
|
||||||
|
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-dig" onclick="demodulator_digital_replace_last();">DIG</div>
|
||||||
|
<select id="openwebrx-secondary-demod-listbox" onchange="secondary_demod_listbox_changed();">
|
||||||
|
<option value="none"></option>
|
||||||
|
<option value="bpsk31">BPSK31</option>
|
||||||
|
<option value="ft8" data-feature="wsjt-x">FT8</option>
|
||||||
|
<option value="wspr" data-feature="wsjt-x">WSPR</option>
|
||||||
|
<option value="jt65" data-feature="wsjt-x">JT65</option>
|
||||||
|
<option value="jt9" data-feature="wsjt-x">JT9</option>
|
||||||
|
<option value="ft4" data-feature="wsjt-x">FT4</option>
|
||||||
|
<option value="packet" data-feature="packet">Packet</option>
|
||||||
|
</select>
|
||||||
|
<div id="openwebrx-secondary-demod-dial-button" class="openwebrx-button openwebrx-dial-button" onclick="dial_button_click();">
|
||||||
|
<svg version="1.1" id="Layer_1" x="0px" y="0px" width="246px" height="246px" viewBox="0 0 246 246" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="ph_dial_1_" transform="matrix(1, 0, 0, 1, -45.398312, -50.931698)">
|
||||||
|
<path id="ph_dial" d="M238.875,190.125c3.853,7.148,34.267,4.219,50.242,2.145c0.891-5.977,1.508-12.043,1.508-18.27 c0-67.723-54.901-122.625-122.625-122.625c-67.723,0-122.625,54.902-122.625,122.625c0,67.723,54.902,122.625,122.625,122.625 c51.06,0,94.797-31.227,113.25-75.609c-13.969-9.668-41.625-18.891-41.625-18.891c-5.25,0-10.5-3-12.75-8.25 S233.625,180.375,238.875,190.125z M220.465,175.313c0,28.478-23.086,51.563-51.563,51.563c-28.478,0-51.563-23.086-51.563-51.563 c0-28.477,23.086-51.563,51.563-51.563C197.379,123.75,220.465,146.836,220.465,175.313z M185.25,64.125 c10.563,0,19.125,8.563,19.125,19.125s-8.563,19.125-19.125,19.125c-10.562,0-19.125-8.563-19.125-19.125 S174.688,64.125,185.25,64.125z M142.875,69C153.438,69,162,77.563,162,88.125s-8.563,19.125-19.125,19.125 c-10.562,0-19.125-8.563-19.125-19.125S132.313,69,142.875,69z M106.5,91.875c10.563,0,19.125,8.563,19.125,19.125 s-8.563,19.125-19.125,19.125c-10.562,0-19.125-8.562-19.125-19.125S95.938,91.875,106.5,91.875z M81.375,126.75 c10.563,0,19.125,8.563,19.125,19.125S91.938,165,81.375,165c-10.563,0-19.125-8.563-19.125-19.125S70.813,126.75,81.375,126.75z M58.125,188.625c0-10.559,8.563-19.125,19.125-19.125c10.563,0,19.125,8.566,19.125,19.125S87.813,207.75,77.25,207.75 C66.687,207.75,58.125,199.184,58.125,188.625z M75.75,229.875c0-10.559,8.563-19.125,19.125-19.125 c10.563,0,19.125,8.566,19.125,19.125S105.438,249,94.875,249C84.312,249,75.75,240.434,75.75,229.875z M126.375,276 c-10.563,0-19.125-8.566-19.125-19.125s8.563-19.125,19.125-19.125c10.563,0,19.125,8.566,19.125,19.125S136.938,276,126.375,276z M168,288c-10.563,0-19.125-8.566-19.125-19.125S157.438,249.75,168,249.75c10.563,0,19.125,8.566,19.125,19.125 S178.563,288,168,288z M210.375,276c-10.563,0-19.125-8.566-19.125-19.125s8.563-19.125,19.125-19.125 c10.563,0,19.125,8.566,19.125,19.125S220.938,276,210.375,276z M243.375,210.75c10.563,0,19.125,8.566,19.125,19.125 S253.938,249,243.375,249c-10.563,0-19.125-8.566-19.125-19.125S232.813,210.75,243.375,210.75z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel-line">
|
||||||
|
<div title="Mute on/off" id="openwebrx-mute-off" class="openwebrx-button" onclick="toggleMute();"><img src="static/gfx/openwebrx-speaker.png" class="openwebrx-sliderbtn-img" id="openwebrx-mute-img"></div>
|
||||||
|
<input title="Volume" id="openwebrx-panel-volume" class="openwebrx-panel-slider" type="range" min="0" max="150" value="50" step="1" onchange="updateVolume()" oninput="updateVolume()">
|
||||||
|
<div title="Auto-adjust waterfall colors" id="openwebrx-waterfall-colors-auto" class="openwebrx-button" onclick="waterfall_measure_minmax_now=true;"><img src="static/gfx/openwebrx-waterfall-auto.png" class="openwebrx-sliderbtn-img"></div>
|
||||||
|
<input title="Waterfall minimum level" id="openwebrx-waterfall-color-min" class="openwebrx-panel-slider" type="range" min="-200" max="100" value="50" step="1" onchange="updateWaterfallColors(0);" oninput="updateVolume()">
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel-line">
|
||||||
|
<div title="Auto-set squelch level" id="openwebrx-squelch-default" class="openwebrx-button" onclick="setSquelchToAuto()"><img src="static/gfx/openwebrx-squelch-button.png" class="openwebrx-sliderbtn-img"></div>
|
||||||
|
<input title="Squelch" id="openwebrx-panel-squelch" class="openwebrx-panel-slider" type="range" min="-150" max="0" value="-150" step="1" onchange="updateSquelch()" oninput="updateSquelch()">
|
||||||
|
<div title="Set waterfall colors to default" id="openwebrx-waterfall-colors-default" class="openwebrx-button" onclick="waterfallColorsDefault()"><img src="static/gfx/openwebrx-waterfall-default.png" class="openwebrx-sliderbtn-img"></div>
|
||||||
|
<input title="Waterfall maximum level" id="openwebrx-waterfall-color-max" class="openwebrx-panel-slider" type="range" min="-200" max="100" value="50" step="1" onchange="updateWaterfallColors(1);" oninput="updateVolume()">
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel-line">
|
||||||
|
<div class="openwebrx-button openwebrx-square-button" onclick="zoomInOneStep();" title="Zoom in one step"> <img src="static/gfx/openwebrx-zoom-in.png" /></div>
|
||||||
|
<div class="openwebrx-button openwebrx-square-button" onclick="zoomOutOneStep();" title="Zoom out one step"> <img src="static/gfx/openwebrx-zoom-out.png" /></div>
|
||||||
|
<div class="openwebrx-button openwebrx-square-button" onclick="zoomInTotal();" title="Zoom in totally"><img src="static/gfx/openwebrx-zoom-in-total.png" /></div>
|
||||||
|
<div class="openwebrx-button openwebrx-square-button" onclick="zoomOutTotal();" title="Zoom out totally"><img src="static/gfx/openwebrx-zoom-out-total.png" /></div>
|
||||||
|
<div class="openwebrx-button openwebrx-square-button" onclick="mathbox_toggle();" title="Toggle 3D view"><img src="static/gfx/openwebrx-3d-spectrum.png" /></div>
|
||||||
|
<div id="openwebrx-smeter-db">0 dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel-line">
|
||||||
|
<div id="openwebrx-smeter-outer">
|
||||||
|
<div id="openwebrx-smeter-bar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel" id="openwebrx-panel-log" data-panel-name="debug" data-panel-pos="left" data-panel-order="1" data-panel-size="619,137">
|
||||||
|
<div class="openwebrx-panel-inner nano" id="openwebrx-log-scroll">
|
||||||
|
<div class="nano-content">
|
||||||
|
<div id="openwebrx-client-log-title">OpenWebRX client log</strong><span id="openwebrx-problems"></span></div>
|
||||||
|
<span id="openwebrx-client-1">Author: </span><a href="http://blog.sdr.hu/about" target="_blank">András Retzler, HA7ILM</a><br />You can support OpenWebRX development via <a href="http://blog.sdr.hu/support" target="_blank">PayPal!</a><br/>
|
||||||
|
<div id="openwebrx-debugdiv"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel" id="openwebrx-panel-status" data-panel-name="status" data-panel-pos="left" data-panel-order="0" data-panel-size="615,50" data-panel-transparent="true">
|
||||||
|
<div class="openwebrx-progressbar" id="openwebrx-bar-audio-buffer"> <span class="openwebrx-progressbar-text">Audio buffer [0 ms]</span><div class="openwebrx-progressbar-bar"></div></div>
|
||||||
|
<div class="openwebrx-progressbar" id="openwebrx-bar-audio-output"> <span class="openwebrx-progressbar-text">Audio output [0 sps]</span><div class="openwebrx-progressbar-bar"></div></div>
|
||||||
|
<div class="openwebrx-progressbar" id="openwebrx-bar-audio-speed"> <span class="openwebrx-progressbar-text">Audio stream [0 kbps]</span><div class="openwebrx-progressbar-bar"></div></div>
|
||||||
|
<div class="openwebrx-progressbar" id="openwebrx-bar-network-speed"> <span class="openwebrx-progressbar-text">Network usage [0 kbps]</span><div class="openwebrx-progressbar-bar"></div></div>
|
||||||
|
<div class="openwebrx-progressbar" id="openwebrx-bar-server-cpu"> <span class="openwebrx-progressbar-text">Server CPU [0%]</span><div class="openwebrx-progressbar-bar"></div></div>
|
||||||
|
<div class="openwebrx-progressbar" id="openwebrx-bar-clients"> <span class="openwebrx-progressbar-text">Clients [1]</span><div class="openwebrx-progressbar-bar"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel" data-panel-name="client-under-devel" data-panel-pos="left" data-panel-order="9" data-panel-size="245,55" style="background-color: Red;">
|
||||||
|
<span style="font-size: 15pt; font-weight: bold;">Under construction</span>
|
||||||
|
<br />We're working on the code right now, so the application might fail.
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel" id="openwebrx-panel-digimodes" data-panel-name="digimodes" data-panel-pos="left" data-panel-order="3" data-panel-size="619,210">
|
||||||
|
<div id="openwebrx-digimode-canvas-container">
|
||||||
|
<div id="openwebrx-digimode-select-channel"></div>
|
||||||
|
</div>
|
||||||
|
<div id="openwebrx-digimode-content-container">
|
||||||
|
<div class="gradient"></div>
|
||||||
|
<div id="openwebrx-digimode-content">
|
||||||
|
<span id="openwebrx-cursor-blink"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="openwebrx-panel" id="openwebrx-panel-wsjt-message" data-panel-name="wsjt-message" data-panel-pos="left" data-panel-order="2" data-panel-size="619,200">
|
||||||
|
<thead><tr>
|
||||||
|
<th>UTC</th>
|
||||||
|
<th class="decimal">dB</th>
|
||||||
|
<th class="decimal">DT</th>
|
||||||
|
<th class="decimal freq">Freq</th>
|
||||||
|
<th class="message">Message</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<table class="openwebrx-panel" id="openwebrx-panel-packet-message" data-panel-name="aprs-message" data-panel-pos="left" data-panel-order="2" data-panel-size="619,200">
|
||||||
|
<thead><tr>
|
||||||
|
<th>UTC</th>
|
||||||
|
<th class="callsign">Callsign</th>
|
||||||
|
<th class="coord">Coord</th>
|
||||||
|
<th class="message">Comment</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-ysf" data-panel-name="metadata-ysf" data-panel-pos="left" data-panel-order="2" data-panel-size="145,220">
|
||||||
|
<div class="openwebrx-meta-frame">
|
||||||
|
<div class="openwebrx-meta-slot">
|
||||||
|
<div class="openwebrx-ysf-mode openwebrx-meta-autoclear"></div>
|
||||||
|
<div class="openwebrx-meta-user-image"></div>
|
||||||
|
<div class="openwebrx-ysf-source openwebrx-meta-autoclear"></div>
|
||||||
|
<div class="openwebrx-ysf-up openwebrx-meta-autoclear"></div>
|
||||||
|
<div class="openwebrx-ysf-down openwebrx-meta-autoclear"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-dmr" data-panel-name="metadata-dmr" data-panel-pos="left" data-panel-order="2" data-panel-size="300,220">
|
||||||
|
<div class="openwebrx-meta-frame">
|
||||||
|
<div class="openwebrx-meta-slot openwebrx-dmr-timeslot-panel">
|
||||||
|
<div class="openwebrx-dmr-slot">Timeslot 1</div>
|
||||||
|
<div class="openwebrx-meta-user-image"></div>
|
||||||
|
<div class="openwebrx-dmr-id openwebrx-meta-autoclear"></div>
|
||||||
|
<div class="openwebrx-dmr-name openwebrx-meta-autoclear"></div>
|
||||||
|
<div class="openwebrx-dmr-target openwebrx-meta-autoclear"></div>
|
||||||
|
</div>
|
||||||
|
<div class="openwebrx-meta-slot openwebrx-dmr-timeslot-panel">
|
||||||
|
<div class="openwebrx-dmr-slot">Timeslot 2</div>
|
||||||
|
<div class="openwebrx-meta-user-image"></div>
|
||||||
|
<div class="openwebrx-dmr-id openwebrx-meta-autoclear"></div>
|
||||||
|
<div class="openwebrx-dmr-name openwebrx-meta-autoclear"></div>
|
||||||
|
<div class="openwebrx-dmr-target openwebrx-meta-autoclear"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="openwebrx-big-grey" onclick="iosPlayButtonClick();">
|
||||||
|
<div id="openwebrx-play-button-text">
|
||||||
|
<img id="openwebrx-play-button" src="static/gfx/openwebrx-play-button.png" />
|
||||||
|
<br /><br />Start OpenWebRX
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="openwebrx-dialog-bookmark" class="openwebrx-dialog" style="display:none;">
|
||||||
|
<form>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="name">Name:</label>
|
||||||
|
<input type="text" id="name" name="name" required="required">
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="frequency">Frequency:</label>
|
||||||
|
<input type="number" id="frequency" name="frequency">
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="modulation">Modulation:</label>
|
||||||
|
<select name="modulation" id="modulation">
|
||||||
|
<option value="nfm">FM</option>
|
||||||
|
<option value="am">AM</option>
|
||||||
|
<option value="usb">USB</option>
|
||||||
|
<option value="lsb">LSB</option>
|
||||||
|
<option value="cw">CW</option>
|
||||||
|
<option value="dmr">DMR</option>
|
||||||
|
<option value="dstar">D-Star</option>
|
||||||
|
<option value="nxdn">NXDN</option>
|
||||||
|
<option value="ysf">YSF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<div class="openwebrx-button" data-action="cancel">Cancel</div>
|
||||||
|
<div class="openwebrx-button" data-action="submit">Ok</div>
|
||||||
|
</div>
|
||||||
|
<input type="submit" style="display:none;">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
180
htdocs/index.wrx
@@ -1,180 +0,0 @@
|
|||||||
<!DOCTYPE HTML>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
This file is part of OpenWebRX,
|
|
||||||
an open-source SDR receiver software with a web UI.
|
|
||||||
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
|
|
||||||
|
|
||||||
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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>OpenWebRX | Open Source SDR Web App for Everyone!</title>
|
|
||||||
<script type="text/javascript">
|
|
||||||
//Global variables
|
|
||||||
var client_id="%[CLIENT_ID]";
|
|
||||||
var ws_url="%[WS_URL]";
|
|
||||||
var rx_photo_height=%[RX_PHOTO_HEIGHT];
|
|
||||||
var audio_buffering_fill_to=%[AUDIO_BUFSIZE];
|
|
||||||
var starting_mod="%[START_MOD]";
|
|
||||||
var starting_offset_frequency = %[START_OFFSET_FREQ];
|
|
||||||
var waterfall_colors=%[WATERFALL_COLORS];
|
|
||||||
var waterfall_min_level_default=%[WATERFALL_MIN_LEVEL];
|
|
||||||
var waterfall_max_level_default=%[WATERFALL_MAX_LEVEL];
|
|
||||||
var waterfall_auto_level_margin=%[WATERFALL_AUTO_LEVEL_MARGIN];
|
|
||||||
var server_enable_digimodes=%[DIGIMODES_ENABLE];
|
|
||||||
var mathbox_waterfall_frequency_resolution=%[MATHBOX_WATERFALL_FRES];
|
|
||||||
var mathbox_waterfall_history_length=%[MATHBOX_WATERFALL_THIST];
|
|
||||||
var mathbox_waterfall_colors=%[MATHBOX_WATERFALL_COLORS];
|
|
||||||
</script>
|
|
||||||
<script src="sdr.js"></script>
|
|
||||||
<script src="mathbox-bundle.min.js"></script>
|
|
||||||
<script src="openwebrx.js"></script>
|
|
||||||
<script src="jquery-3.2.1.min.js"></script>
|
|
||||||
<script src="jquery.nanoscroller.js"></script>
|
|
||||||
<link rel="stylesheet" type="text/css" href="nanoscroller.css" />
|
|
||||||
<link rel="stylesheet" type="text/css" href="openwebrx.css" />
|
|
||||||
<meta charset="utf-8">
|
|
||||||
</head>
|
|
||||||
<body onload="openwebrx_init();">
|
|
||||||
<div id="webrx-page-container">
|
|
||||||
<div id="webrx-top-container">
|
|
||||||
<div id="webrx-top-photo-clip">
|
|
||||||
<img src="gfx/openwebrx-top-photo.jpg" id="webrx-top-photo"/>
|
|
||||||
<div id="webrx-rx-photo-title">%[RX_PHOTO_TITLE]</div>
|
|
||||||
<div id="webrx-rx-photo-desc">%[RX_PHOTO_DESC]</div>
|
|
||||||
</div>
|
|
||||||
<div id="webrx-top-bar-background" class="webrx-top-bar-parts"></div>
|
|
||||||
<div id="webrx-top-bar" class="webrx-top-bar-parts">
|
|
||||||
<a href="https://sdr.hu/openwebrx" target="_blank"><img src="gfx/openwebrx-top-logo.png" id="webrx-top-logo" /></a>
|
|
||||||
<a href="http://ha5kfu.sch.bme.hu/" target="_blank"><img src="gfx/openwebrx-ha5kfu-top-logo.png" id="webrx-ha5kfu-top-logo" /></a>
|
|
||||||
<img id="webrx-rx-avatar-background" src="gfx/openwebrx-avatar-background.png" onclick="toggle_rx_photo();"/>
|
|
||||||
<img id="webrx-rx-avatar" src="gfx/openwebrx-avatar.png" onclick="toggle_rx_photo();"/>
|
|
||||||
<div id="webrx-rx-title" onclick="toggle_rx_photo();">%[RX_TITLE]</div>
|
|
||||||
<div id="webrx-rx-desc" onclick="toggle_rx_photo();">%[RX_LOC] | Loc: %[RX_QRA], ASL: %[RX_ASL] m, <a href="https://www.google.hu/maps/place/%[RX_GPS]" target="_blank" onclick="dont_toggle_rx_photo();">[maps]</a></div>
|
|
||||||
<div id="openwebrx-rx-details-arrow">
|
|
||||||
<a id="openwebrx-rx-details-arrow-up" onclick="toggle_rx_photo();"><img src="gfx/openwebrx-rx-details-arrow-up.png" /></a>
|
|
||||||
<a id="openwebrx-rx-details-arrow-down" onclick="toggle_rx_photo();"><img src="gfx/openwebrx-rx-details-arrow.png" /></a>
|
|
||||||
</div>
|
|
||||||
<section id="openwebrx-main-buttons">
|
|
||||||
<ul>
|
|
||||||
<li onmouseup="toggle_panel('openwebrx-panel-status');"><img src="gfx/openwebrx-panel-status.png" /><br/>Status</li>
|
|
||||||
<li onmouseup="toggle_panel('openwebrx-panel-log');"><img src="gfx/openwebrx-panel-log.png" /><br/>Log</li>
|
|
||||||
<li onmouseup="toggle_panel('openwebrx-panel-receiver');"><img src="gfx/openwebrx-panel-receiver.png" /><br/>Receiver</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="webrx-main-container">
|
|
||||||
<div id="openwebrx-scale-container">
|
|
||||||
<canvas id="openwebrx-scale-canvas" width="0" height="0"></canvas>
|
|
||||||
</div>
|
|
||||||
<div id="openwebrx-mathbox-container"> </div>
|
|
||||||
<div id="webrx-canvas-container">
|
|
||||||
<div id="openwebrx-phantom-canvas"></div>
|
|
||||||
<!-- add canvas here by javascript -->
|
|
||||||
</div>
|
|
||||||
<div id="openwebrx-panels-container">
|
|
||||||
<div class="openwebrx-panel" id="openwebrx-panel-receiver" data-panel-name="client-params" data-panel-pos="right" data-panel-order="0" data-panel-size="259,115">
|
|
||||||
<div id="webrx-actual-freq">---.--- MHz</div>
|
|
||||||
<div id="webrx-mouse-freq">---.--- MHz</div>
|
|
||||||
<div class="openwebrx-panel-line">
|
|
||||||
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-nfm"
|
|
||||||
onclick="demodulator_analog_replace('nfm');">FM</div>
|
|
||||||
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-am"
|
|
||||||
onclick="demodulator_analog_replace('am');">AM</div>
|
|
||||||
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-lsb"
|
|
||||||
onclick="demodulator_analog_replace('lsb');">LSB</div>
|
|
||||||
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-usb"
|
|
||||||
onclick="demodulator_analog_replace('usb');">USB</div>
|
|
||||||
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-cw"
|
|
||||||
onclick="demodulator_analog_replace('cw');">CW</div>
|
|
||||||
</div>
|
|
||||||
<div class="openwebrx-panel-line">
|
|
||||||
<div class="openwebrx-button openwebrx-demodulator-button" id="openwebrx-button-dig" onclick="demodulator_digital_replace_last();">DIG</div>
|
|
||||||
<select id="openwebrx-secondary-demod-listbox" onchange="secondary_demod_listbox_changed();">
|
|
||||||
<option value="none"></option>
|
|
||||||
<option value="bpsk31">BPSK31</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="openwebrx-panel-line">
|
|
||||||
<div title="Mute on/off" id="openwebrx-mute-off" class="openwebrx-button" onclick="toggleMute();"><img src="gfx/openwebrx-speaker.png" class="openwebrx-sliderbtn-img" id="openwebrx-mute-img"></div>
|
|
||||||
<input title="Volume" id="openwebrx-panel-volume" class="openwebrx-panel-slider" type="range" min="0" max="150" value="50" step="1" onchange="updateVolume()" oninput="updateVolume()">
|
|
||||||
<div title="Auto-adjust waterfall colors" id="openwebrx-waterfall-colors-auto" class="openwebrx-button" onclick="waterfall_measure_minmax_now=true;"><img src="gfx/openwebrx-waterfall-auto.png" class="openwebrx-sliderbtn-img"></div>
|
|
||||||
<input title="Waterfall minimum level" id="openwebrx-waterfall-color-min" class="openwebrx-panel-slider" type="range" min="-200" max="100" value="50" step="1" onchange="updateWaterfallColors(0);" oninput="updateVolume()">
|
|
||||||
</div>
|
|
||||||
<div class="openwebrx-panel-line">
|
|
||||||
<div title="Auto-set squelch level" id="openwebrx-squelch-default" class="openwebrx-button" onclick="setSquelchToAuto()"><img src="gfx/openwebrx-squelch-button.png" class="openwebrx-sliderbtn-img"></div>
|
|
||||||
<input title="Squelch" id="openwebrx-panel-squelch" class="openwebrx-panel-slider" type="range" min="-150" max="0" value="-150" step="1" onchange="updateSquelch()" oninput="updateSquelch()">
|
|
||||||
<div title="Set waterfall colors to default" id="openwebrx-waterfall-colors-default" class="openwebrx-button" onclick="waterfallColorsDefault()"><img src="gfx/openwebrx-waterfall-default.png" class="openwebrx-sliderbtn-img"></div>
|
|
||||||
<input title="Waterfall maximum level" id="openwebrx-waterfall-color-max" class="openwebrx-panel-slider" type="range" min="-200" max="100" value="50" step="1" onchange="updateWaterfallColors(1);" oninput="updateVolume()">
|
|
||||||
</div>
|
|
||||||
<div class="openwebrx-panel-line">
|
|
||||||
<div class="openwebrx-button openwebrx-square-button" onclick="zoomInOneStep();" title="Zoom in one step"> <img src="gfx/openwebrx-zoom-in.png" /></div>
|
|
||||||
<div class="openwebrx-button openwebrx-square-button" onclick="zoomOutOneStep();" title="Zoom out one step"> <img src="gfx/openwebrx-zoom-out.png" /></div>
|
|
||||||
<div class="openwebrx-button openwebrx-square-button" onclick="zoomInTotal();" title="Zoom in totally"><img src="gfx/openwebrx-zoom-in-total.png" /></div>
|
|
||||||
<div class="openwebrx-button openwebrx-square-button" onclick="zoomOutTotal();" title="Zoom out totally"><img src="gfx/openwebrx-zoom-out-total.png" /></div>
|
|
||||||
<div class="openwebrx-button openwebrx-square-button" onclick="mathbox_toggle();" title="Toggle 3D view"><img src="gfx/openwebrx-3d-spectrum.png" /></div>
|
|
||||||
<div id="openwebrx-smeter-db">0 dB</div>
|
|
||||||
</div>
|
|
||||||
<div class="openwebrx-panel-line">
|
|
||||||
<div id="openwebrx-smeter-outer">
|
|
||||||
<div id="openwebrx-smeter-bar"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="openwebrx-panel" id="openwebrx-panel-log" data-panel-name="debug" data-panel-pos="left" data-panel-order="1" data-panel-size="619,137">
|
|
||||||
<div class="openwebrx-panel-inner nano" id="openwebrx-log-scroll">
|
|
||||||
<div class="nano-content">
|
|
||||||
<div id="openwebrx-client-log-title">OpenWebRX client log</strong><span id="openwebrx-problems"></span></div>
|
|
||||||
<span id="openwebrx-client-1">Author: </span><a href="http://blog.sdr.hu/about" target="_blank">András Retzler, HA7ILM</a><br />You can <a href="http://blog.sdr.hu/support" target="_blank">donate</a> to say thanks for former development (this is the final version).<br/>
|
|
||||||
<div id="openwebrx-debugdiv"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="openwebrx-panel" id="openwebrx-panel-status" data-panel-name="status" data-panel-pos="left" data-panel-order="0" data-panel-size="615,50" data-panel-transparent="true">
|
|
||||||
<div class="openwebrx-progressbar" id="openwebrx-bar-audio-buffer"> <span class="openwebrx-progressbar-text">Audio buffer [0 ms]</span><div class="openwebrx-progressbar-bar"></div></div>
|
|
||||||
<div class="openwebrx-progressbar" id="openwebrx-bar-audio-output"> <span class="openwebrx-progressbar-text">Audio output [0 sps]</span><div class="openwebrx-progressbar-bar"></div></div>
|
|
||||||
<div class="openwebrx-progressbar" id="openwebrx-bar-audio-speed"> <span class="openwebrx-progressbar-text">Audio stream [0 kbps]</span><div class="openwebrx-progressbar-bar"></div></div>
|
|
||||||
<div class="openwebrx-progressbar" id="openwebrx-bar-network-speed"> <span class="openwebrx-progressbar-text">Network usage [0 kbps]</span><div class="openwebrx-progressbar-bar"></div></div>
|
|
||||||
<div class="openwebrx-progressbar" id="openwebrx-bar-server-cpu"> <span class="openwebrx-progressbar-text">Server CPU [0%]</span><div class="openwebrx-progressbar-bar"></div></div>
|
|
||||||
<div class="openwebrx-progressbar" id="openwebrx-bar-clients"> <span class="openwebrx-progressbar-text">Clients [1]</span><div class="openwebrx-progressbar-bar"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="openwebrx-panel" data-panel-name="client-under-devel" data-panel-pos="none" data-panel-order="0" data-panel-size="245,55" style="background-color: Red;">
|
|
||||||
<span style="font-size: 15pt; font-weight: bold;">Under construction</span>
|
|
||||||
<br />We're working on the code right now, so the application might fail.
|
|
||||||
</div>
|
|
||||||
<div class="openwebrx-panel" id="openwebrx-panel-digimodes" data-panel-name="digimodes" data-panel-pos="left" data-panel-order="2" data-panel-size="619,210">
|
|
||||||
<div id="openwebrx-digimode-canvas-container">
|
|
||||||
<div id="openwebrx-digimode-select-channel"></div>
|
|
||||||
</div>
|
|
||||||
<div id="openwebrx-digimode-content-container">
|
|
||||||
<div class="gradient"></div>
|
|
||||||
<div id="openwebrx-digimode-content">
|
|
||||||
<span id="openwebrx-cursor-blink"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="openwebrx-big-grey" onclick="iosPlayButtonClick();">
|
|
||||||
<div id="openwebrx-play-button-text">
|
|
||||||
<img id="openwebrx-play-button" src="gfx/openwebrx-play-button.png" />
|
|
||||||
<br /><br />Start OpenWebRX
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
91
htdocs/lib/AprsMarker.js
Normal file
@@ -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);
|
||||||
|
};
|
||||||
58
htdocs/lib/chroma.min.js
vendored
Normal file
143
htdocs/lib/nite-overlay.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/* Nite v1.7
|
||||||
|
* A tiny library to create a night overlay over the map
|
||||||
|
* Author: Rossen Georgiev @ https://github.com/rossengeorgiev
|
||||||
|
* Requires: GMaps API 3
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
var nite = {
|
||||||
|
map: null,
|
||||||
|
date: null,
|
||||||
|
sun_position: null,
|
||||||
|
earth_radius_meters: 6371008,
|
||||||
|
marker_twilight_civil: null,
|
||||||
|
marker_twilight_nautical: null,
|
||||||
|
marker_twilight_astronomical: null,
|
||||||
|
marker_night: null,
|
||||||
|
|
||||||
|
init: function(map) {
|
||||||
|
if(typeof google === 'undefined'
|
||||||
|
|| typeof google.maps === 'undefined') throw "Nite Overlay: no google.maps detected";
|
||||||
|
|
||||||
|
this.map = map;
|
||||||
|
this.sun_position = this.calculatePositionOfSun();
|
||||||
|
|
||||||
|
this.marker_twilight_civil = new google.maps.Circle({
|
||||||
|
map: this.map,
|
||||||
|
center: this.getShadowPosition(),
|
||||||
|
radius: this.getShadowRadiusFromAngle(0.566666),
|
||||||
|
fillColor: "#000",
|
||||||
|
fillOpacity: 0.1,
|
||||||
|
strokeOpacity: 0,
|
||||||
|
clickable: false,
|
||||||
|
editable: false
|
||||||
|
});
|
||||||
|
this.marker_twilight_nautical = new google.maps.Circle({
|
||||||
|
map: this.map,
|
||||||
|
center: this.getShadowPosition(),
|
||||||
|
radius: this.getShadowRadiusFromAngle(6),
|
||||||
|
fillColor: "#000",
|
||||||
|
fillOpacity: 0.1,
|
||||||
|
strokeOpacity: 0,
|
||||||
|
clickable: false,
|
||||||
|
editable: false
|
||||||
|
});
|
||||||
|
this.marker_twilight_astronomical = new google.maps.Circle({
|
||||||
|
map: this.map,
|
||||||
|
center: this.getShadowPosition(),
|
||||||
|
radius: this.getShadowRadiusFromAngle(12),
|
||||||
|
fillColor: "#000",
|
||||||
|
fillOpacity: 0.1,
|
||||||
|
strokeOpacity: 0,
|
||||||
|
clickable: false,
|
||||||
|
editable: false
|
||||||
|
});
|
||||||
|
this.marker_night = new google.maps.Circle({
|
||||||
|
map: this.map,
|
||||||
|
center: this.getShadowPosition(),
|
||||||
|
radius: this.getShadowRadiusFromAngle(18),
|
||||||
|
fillColor: "#000",
|
||||||
|
fillOpacity: 0.1,
|
||||||
|
strokeOpacity: 0,
|
||||||
|
clickable: false,
|
||||||
|
editable: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getShadowRadiusFromAngle: function(angle) {
|
||||||
|
var shadow_radius = this.earth_radius_meters * Math.PI * 0.5;
|
||||||
|
var twilight_dist = ((this.earth_radius_meters * 2 * Math.PI) / 360) * angle;
|
||||||
|
return shadow_radius - twilight_dist;
|
||||||
|
},
|
||||||
|
getSunPosition: function() {
|
||||||
|
return this.sun_position;
|
||||||
|
},
|
||||||
|
getShadowPosition: function() {
|
||||||
|
return (this.sun_position) ? new google.maps.LatLng(-this.sun_position.lat(), this.sun_position.lng() + 180) : null;
|
||||||
|
},
|
||||||
|
refresh: function() {
|
||||||
|
if(!this.isVisible()) return;
|
||||||
|
this.sun_position = this.calculatePositionOfSun(this.date);
|
||||||
|
var shadow_position = this.getShadowPosition();
|
||||||
|
this.marker_twilight_civil.setCenter(shadow_position);
|
||||||
|
this.marker_twilight_nautical.setCenter(shadow_position);
|
||||||
|
this.marker_twilight_astronomical.setCenter(shadow_position);
|
||||||
|
this.marker_night.setCenter(shadow_position);
|
||||||
|
},
|
||||||
|
jday: function(date) {
|
||||||
|
return (date.getTime() / 86400000.0) + 2440587.5;
|
||||||
|
},
|
||||||
|
calculatePositionOfSun: function(date) {
|
||||||
|
date = (date instanceof Date) ? date : new Date();
|
||||||
|
|
||||||
|
var rad = 0.017453292519943295;
|
||||||
|
|
||||||
|
// based on NOAA solar calculations
|
||||||
|
var ms_past_midnight = ((date.getUTCHours() * 60 + date.getUTCMinutes()) * 60 + date.getUTCSeconds()) * 1000 + date.getUTCMilliseconds();
|
||||||
|
var jc = (this.jday(date) - 2451545)/36525;
|
||||||
|
var mean_long_sun = (280.46646+jc*(36000.76983+jc*0.0003032)) % 360;
|
||||||
|
var mean_anom_sun = 357.52911+jc*(35999.05029-0.0001537*jc);
|
||||||
|
var sun_eq = Math.sin(rad*mean_anom_sun)*(1.914602-jc*(0.004817+0.000014*jc))+Math.sin(rad*2*mean_anom_sun)*(0.019993-0.000101*jc)+Math.sin(rad*3*mean_anom_sun)*0.000289;
|
||||||
|
var sun_true_long = mean_long_sun + sun_eq;
|
||||||
|
var sun_app_long = sun_true_long - 0.00569 - 0.00478*Math.sin(rad*125.04-1934.136*jc);
|
||||||
|
var mean_obliq_ecliptic = 23+(26+((21.448-jc*(46.815+jc*(0.00059-jc*0.001813))))/60)/60;
|
||||||
|
var obliq_corr = mean_obliq_ecliptic + 0.00256*Math.cos(rad*125.04-1934.136*jc);
|
||||||
|
|
||||||
|
var lat = Math.asin(Math.sin(rad*obliq_corr)*Math.sin(rad*sun_app_long)) / rad;
|
||||||
|
|
||||||
|
var eccent = 0.016708634-jc*(0.000042037+0.0000001267*jc);
|
||||||
|
var y = Math.tan(rad*(obliq_corr/2))*Math.tan(rad*(obliq_corr/2));
|
||||||
|
var rq_of_time = 4*((y*Math.sin(2*rad*mean_long_sun)-2*eccent*Math.sin(rad*mean_anom_sun)+4*eccent*y*Math.sin(rad*mean_anom_sun)*Math.cos(2*rad*mean_long_sun)-0.5*y*y*Math.sin(4*rad*mean_long_sun)-1.25*eccent*eccent*Math.sin(2*rad*mean_anom_sun))/rad);
|
||||||
|
var true_solar_time_in_deg = ((ms_past_midnight+rq_of_time*60000) % 86400000) / 240000;
|
||||||
|
|
||||||
|
var lng = -((true_solar_time_in_deg < 0) ? true_solar_time_in_deg + 180 : true_solar_time_in_deg - 180);
|
||||||
|
|
||||||
|
return new google.maps.LatLng(lat, lng);
|
||||||
|
},
|
||||||
|
setDate: function(date) {
|
||||||
|
this.date = date;
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
setMap: function(map) {
|
||||||
|
this.map = map;
|
||||||
|
this.marker_twilight_civil.setMap(this.map);
|
||||||
|
this.marker_twilight_nautical.setMap(this.map);
|
||||||
|
this.marker_twilight_astronomical.setMap(this.map);
|
||||||
|
this.marker_night.setMap(this.map);
|
||||||
|
},
|
||||||
|
show: function() {
|
||||||
|
this.marker_twilight_civil.setVisible(true);
|
||||||
|
this.marker_twilight_nautical.setVisible(true);
|
||||||
|
this.marker_twilight_astronomical.setVisible(true);
|
||||||
|
this.marker_night.setVisible(true);
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
hide: function() {
|
||||||
|
this.marker_twilight_civil.setVisible(false);
|
||||||
|
this.marker_twilight_nautical.setVisible(false);
|
||||||
|
this.marker_twilight_astronomical.setVisible(false);
|
||||||
|
this.marker_night.setVisible(false);
|
||||||
|
},
|
||||||
|
isVisible: function() {
|
||||||
|
return this.marker_night.getVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
htdocs/map.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE HTML>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenWebRX Map</title>
|
||||||
|
<script src="static/lib/jquery-3.2.1.min.js"></script>
|
||||||
|
<script src="static/lib/chroma.min.js"></script>
|
||||||
|
<script src="static/map.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||||
|
<link rel="stylesheet" type="text/css" href="static/css/map.css" />
|
||||||
|
<meta charset="utf-8">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${header}
|
||||||
|
<div class="openwebrx-map"></div>
|
||||||
|
<div class="openwebrx-map-legend">
|
||||||
|
<h3>Colors</h3>
|
||||||
|
<select id="openwebrx-map-colormode">
|
||||||
|
<option value="byband" selected="selected">By Band</option>
|
||||||
|
<option value="bymode">By Mode</option>
|
||||||
|
</select>
|
||||||
|
<div class="content"></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
356
htdocs/map.js
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
(function(){
|
||||||
|
var protocol = 'ws';
|
||||||
|
if (window.location.toString().startsWith('https://')) {
|
||||||
|
protocol = 'wss';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = query.callsign;
|
||||||
|
var expectedLocator;
|
||||||
|
if (query.locator) expectedLocator = query.locator;
|
||||||
|
|
||||||
|
var ws_url = protocol + "://" + (window.location.origin.split("://")[1]) + "/ws/";
|
||||||
|
if (!("WebSocket" in window)) return;
|
||||||
|
|
||||||
|
var map;
|
||||||
|
var markers = {};
|
||||||
|
var rectangles = {};
|
||||||
|
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();
|
||||||
|
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 = {};
|
||||||
|
reColor();
|
||||||
|
updateLegend();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var updateLegend = function() {
|
||||||
|
var lis = $.map(colorKeys, function(value, key) {
|
||||||
|
return '<li class="square"><span class="illustration" style="background-color:' + chroma(value).alpha(fillOpacity) + ';border-color:' + chroma(value).alpha(strokeOpacity) + ';"></span>' + key + '</li>';
|
||||||
|
});
|
||||||
|
$(".openwebrx-map-legend .content").html('<ul>' + lis.join('') + '</ul>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// TODO the trim should happen on the server side
|
||||||
|
if (expectedCallsign && expectedCallsign == update.callsign.trim()) {
|
||||||
|
map.panTo(pos);
|
||||||
|
showMarkerInfoWindow(update.callsign, pos);
|
||||||
|
delete(expectedCallsign);
|
||||||
|
}
|
||||||
|
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.setOptions($.extend({
|
||||||
|
strokeColor: color,
|
||||||
|
strokeWeight: 2,
|
||||||
|
fillColor: color,
|
||||||
|
map: map,
|
||||||
|
bounds:{
|
||||||
|
north: lat,
|
||||||
|
south: lat + 1,
|
||||||
|
west: lon,
|
||||||
|
east: lon + 2
|
||||||
|
}
|
||||||
|
}, getRectangleOpacityOptions(update.lastseen) ));
|
||||||
|
rectangle.lastseen = update.lastseen;
|
||||||
|
rectangle.locator = update.location.locator;
|
||||||
|
rectangle.mode = update.mode;
|
||||||
|
rectangle.band = update.band;
|
||||||
|
rectangle.center = center;
|
||||||
|
|
||||||
|
if (expectedLocator && expectedLocator == update.location.locator) {
|
||||||
|
map.panTo(center);
|
||||||
|
showLocatorInfoWindow(expectedLocator, center);
|
||||||
|
delete(expectedLocator);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var clearMap = function(){
|
||||||
|
var reset = function(callsign, item) { item.setMap(); };
|
||||||
|
$.each(markers, reset);
|
||||||
|
$.each(rectangles, reset);
|
||||||
|
markers = {};
|
||||||
|
rectangles = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
var reconnect_timeout = false;
|
||||||
|
|
||||||
|
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") {
|
||||||
|
console.log("Server acknowledged WebSocket connection.");
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var json = JSON.parse(e.data);
|
||||||
|
switch (json.type) {
|
||||||
|
case "config":
|
||||||
|
var config = json.value;
|
||||||
|
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: {
|
||||||
|
lat: config.receiver_gps[0],
|
||||||
|
lng: config.receiver_gps[1]
|
||||||
|
},
|
||||||
|
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 = [];
|
||||||
|
});
|
||||||
|
map.controls[google.maps.ControlPosition.LEFT_BOTTOM].push($(".openwebrx-map-legend")[0]);
|
||||||
|
});
|
||||||
|
retention_time = config.map_position_retention_time * 1000;
|
||||||
|
break;
|
||||||
|
case "update":
|
||||||
|
processUpdates(json.value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} 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 infowindow;
|
||||||
|
var showLocatorInfoWindow = function(locator, pos) {
|
||||||
|
if (!infowindow) infowindow = new google.maps.InfoWindow();
|
||||||
|
var inLocator = $.map(rectangles, function(r, callsign) {
|
||||||
|
return {callsign: callsign, locator: r.locator, lastseen: r.lastseen, mode: r.mode, band: r.band}
|
||||||
|
}).filter(function(d) {
|
||||||
|
return d.locator == locator;
|
||||||
|
}).sort(function(a, b){
|
||||||
|
return b.lastseen - a.lastseen;
|
||||||
|
});
|
||||||
|
infowindow.setContent(
|
||||||
|
'<h3>Locator: ' + locator + '</h3>' +
|
||||||
|
'<div>Active Callsigns:</div>' +
|
||||||
|
'<ul>' +
|
||||||
|
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 '<li>' + message + '</li>'
|
||||||
|
}).join("") +
|
||||||
|
'</ul>'
|
||||||
|
);
|
||||||
|
infowindow.setPosition(pos);
|
||||||
|
infowindow.open(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
var showMarkerInfoWindow = function(callsign, pos) {
|
||||||
|
if (!infowindow) infowindow = new google.maps.InfoWindow();
|
||||||
|
var marker = markers[callsign];
|
||||||
|
var timestring = moment(marker.lastseen).fromNow();
|
||||||
|
var commentString = "";
|
||||||
|
if (marker.comment) {
|
||||||
|
commentString = '<div>' + marker.comment + '</div>';
|
||||||
|
}
|
||||||
|
infowindow.setContent(
|
||||||
|
'<h3>' + callsign + '</h3>' +
|
||||||
|
'<div>' + timestring + ' using ' + marker.mode + ( marker.band ? ' on ' + marker.band : '' ) + '</div>' +
|
||||||
|
commentString
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<html>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
This file is part of OpenWebRX,
|
|
||||||
an open-source SDR receiver software with a web UI.
|
|
||||||
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
|
|
||||||
|
|
||||||
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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<head><title>OpenWebRX</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
||||||
<style>
|
|
||||||
html, body
|
|
||||||
{
|
|
||||||
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
img.logo
|
|
||||||
{
|
|
||||||
margin-top: 120px;
|
|
||||||
}
|
|
||||||
div.frame
|
|
||||||
{
|
|
||||||
text-align: left;
|
|
||||||
margin:0px auto;
|
|
||||||
width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.panel
|
|
||||||
{
|
|
||||||
text-align: center;
|
|
||||||
background-color:#777777;
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: White;
|
|
||||||
font-size: 13pt;
|
|
||||||
/*text-shadow: 1px 1px 4px #444;*/
|
|
||||||
font-family: sans;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.alt
|
|
||||||
{
|
|
||||||
font-size: 10pt;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
body div a
|
|
||||||
{
|
|
||||||
color: #5ca8ff;
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.browser
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
var irt = function (s,n) {return s.replace(/[a-zA-Z]/g,function(c){return String.fromCharCode((c>="a"?97:65)<=(c=c.charCodeAt(0)-n)?c:c+26);});}
|
|
||||||
var sendmail2 = function (s) { window.location.href="mailto:"+irt(s.replace("=",String.fromCharCode(0100)).replace("$","."),8); }
|
|
||||||
window.addEventListener("load",function(){rs=document.getElementById("reconnect-secs"); rt=document.getElementById("reconnect-text"); cnt=29;window.setInterval(function(){if(cnt<=-1) window.location.href=window.location.href.split("retry.")[0]; else if(cnt==0) {rt.innerHTML="Reconnecting..."; cnt--;} else rs.innerHTML=(cnt--).toString();},1000);},false);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="frame">
|
|
||||||
<img class="logo" src="gfx/openwebrx-logo-big.png" style="height: 60px;"/>
|
|
||||||
<div class="panel">
|
|
||||||
There are no client slots left on this server.
|
|
||||||
<div class="alt">
|
|
||||||
Please wait until a client disconnects.<br /><span id="reconnect-text">We will try to reconnect in <span id="reconnect-secs">30</span> seconds...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<html>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
This file is part of OpenWebRX,
|
|
||||||
an open-source SDR receiver software with a web UI.
|
|
||||||
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
|
|
||||||
|
|
||||||
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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<head><title>OpenWebRX</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
||||||
<style>
|
|
||||||
html, body
|
|
||||||
{
|
|
||||||
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
img.logo
|
|
||||||
{
|
|
||||||
margin-top: 120px;
|
|
||||||
}
|
|
||||||
div.frame
|
|
||||||
{
|
|
||||||
text-align: left;
|
|
||||||
margin:0px auto;
|
|
||||||
width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.panel
|
|
||||||
{
|
|
||||||
text-align: center;
|
|
||||||
background-color:#777777;
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: White;
|
|
||||||
font-size: 13pt;
|
|
||||||
/*text-shadow: 1px 1px 4px #444;*/
|
|
||||||
font-family: sans;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.alt
|
|
||||||
{
|
|
||||||
font-size: 10pt;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
body div a
|
|
||||||
{
|
|
||||||
color: #5ca8ff;
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.browser
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
var irt = function (s,n) {return s.replace(/[a-zA-Z]/g,function(c){return String.fromCharCode((c>="a"?97:65)<=(c=c.charCodeAt(0)-n)?c:c+26);});}
|
|
||||||
var sendmail2 = function (s) { window.location.href="mailto:"+irt(s.replace("=",String.fromCharCode(0100)).replace("$","."),8); }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="frame">
|
|
||||||
<img class="logo" src="gfx/openwebrx-logo-big.png" style="height: 60px;"/>
|
|
||||||
<div class="panel">
|
|
||||||
Only the latest <span class="browser">Google Chrome</span> browser is supported at the moment.<br/>
|
|
||||||
Please <a href="http://chrome.google.com/">download and install Google Chrome.</a><br />
|
|
||||||
<div class="alt">
|
|
||||||
Alternatively, you may proceed to OpenWebRX, but it's not supposed to work as expected. <br />
|
|
||||||
<a href="/?unsupported">Click here</a> if you still want to try OpenWebRX.</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
761
openwebrx.py
@@ -1,739 +1,62 @@
|
|||||||
#!/usr/bin/python2
|
#!/usr/bin/env python3
|
||||||
print "" # python2.7 is required to run OpenWebRX instead of python3. Please run me by: python2 openwebrx.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
This file is part of OpenWebRX,
|
from http.server import HTTPServer
|
||||||
an open-source SDR receiver software with a web UI.
|
from owrx.http import RequestHandler
|
||||||
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
|
from owrx.config import PropertyManager
|
||||||
|
from owrx.feature import FeatureDetector
|
||||||
|
from owrx.source import SdrService
|
||||||
|
from socketserver import ThreadingMixIn
|
||||||
|
from owrx.sdrhu import SdrHuUpdater
|
||||||
|
from owrx.service import Services
|
||||||
|
from owrx.websocket import WebSocketConnection
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
import logging
|
||||||
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,
|
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||||
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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
"""
|
class ThreadedHttpServer(ThreadingMixIn, HTTPServer):
|
||||||
sw_version="v0.17"
|
|
||||||
#0.15 (added nmux)
|
|
||||||
|
|
||||||
import os
|
|
||||||
import code
|
|
||||||
import importlib
|
|
||||||
import csdr
|
|
||||||
import thread
|
|
||||||
import time
|
|
||||||
import datetime
|
|
||||||
import subprocess
|
|
||||||
import os
|
|
||||||
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
|
||||||
from SocketServer import ThreadingMixIn
|
|
||||||
import fcntl
|
|
||||||
import time
|
|
||||||
import md5
|
|
||||||
import random
|
|
||||||
import threading
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
from collections import namedtuple
|
|
||||||
import Queue
|
|
||||||
import ctypes
|
|
||||||
|
|
||||||
#import rtl_mus
|
|
||||||
import rxws
|
|
||||||
import uuid
|
|
||||||
import signal
|
|
||||||
import socket
|
|
||||||
|
|
||||||
try: import sdrhu
|
|
||||||
except: sdrhu=False
|
|
||||||
avatar_ctime=""
|
|
||||||
|
|
||||||
#pypy compatibility
|
|
||||||
try: import dl
|
|
||||||
except: pass
|
|
||||||
try: import __pypy__
|
|
||||||
except: pass
|
|
||||||
pypy="__pypy__" in globals()
|
|
||||||
|
|
||||||
"""
|
|
||||||
def import_all_plugins(directory):
|
|
||||||
for subdir in os.listdir(directory):
|
|
||||||
if os.path.isdir(directory+subdir) and not subdir[0]=="_":
|
|
||||||
exact_path=directory+subdir+"/plugin.py"
|
|
||||||
if os.path.isfile(exact_path):
|
|
||||||
importname=(directory+subdir+"/plugin").replace("/",".")
|
|
||||||
print "[openwebrx-import] Found plugin:",importname
|
|
||||||
importlib.import_module(importname)
|
|
||||||
"""
|
|
||||||
|
|
||||||
class MultiThreadHTTPServer(ThreadingMixIn, HTTPServer):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def handle_signal(sig, frame):
|
|
||||||
global spectrum_dsp
|
|
||||||
if sig == signal.SIGUSR1:
|
|
||||||
print "[openwebrx] Verbose status information on USR1 signal"
|
|
||||||
print
|
|
||||||
print "time.time() =", time.time()
|
|
||||||
print "clients_mutex.locked() =", clients_mutex.locked()
|
|
||||||
print "clients_mutex_locker =", clients_mutex_locker
|
|
||||||
if server_fail: print "server_fail = ", server_fail
|
|
||||||
print "spectrum_thread_watchdog_last_tick =", spectrum_thread_watchdog_last_tick
|
|
||||||
print
|
|
||||||
print "clients:",len(clients)
|
|
||||||
for client in clients:
|
|
||||||
print
|
|
||||||
for key in client._fields:
|
|
||||||
print "\t%s = %s"%(key,str(getattr(client,key)))
|
|
||||||
elif sig == signal.SIGUSR2:
|
|
||||||
code.interact(local=globals())
|
|
||||||
else:
|
|
||||||
print "[openwebrx] Ctrl+C: aborting."
|
|
||||||
cleanup_clients(True)
|
|
||||||
spectrum_dsp.stop()
|
|
||||||
os._exit(1) #not too graceful exit
|
|
||||||
|
|
||||||
def access_log(data):
|
|
||||||
global logs
|
|
||||||
logs.access_log.write("["+datetime.datetime.now().isoformat()+"] "+data+"\n")
|
|
||||||
logs.access_log.flush()
|
|
||||||
|
|
||||||
receiver_failed=spectrum_thread_watchdog_last_tick=rtl_thread=spectrum_dsp=server_fail=None
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global clients, clients_mutex, pypy, lock_try_time, avatar_ctime, cfg, logs
|
print(
|
||||||
global serverfail, rtl_thread
|
"""
|
||||||
print
|
|
||||||
print "OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE file in the package"
|
|
||||||
print "_________________________________________________________________________________________________"
|
|
||||||
print
|
|
||||||
print "Author contact info: Andras Retzler, HA7ILM <randras@sdr.hu>"
|
|
||||||
print
|
|
||||||
|
|
||||||
no_arguments=len(sys.argv)==1
|
OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE file in the package
|
||||||
if no_arguments: print "[openwebrx-main] Configuration script not specified. I will use: \"config_webrx.py\""
|
_________________________________________________________________________________________________
|
||||||
cfg=__import__("config_webrx" if no_arguments else sys.argv[1])
|
|
||||||
for option in ("access_log","csdr_dynamic_bufsize","csdr_print_bufsizes","csdr_through"):
|
|
||||||
if not option in dir(cfg): setattr(cfg, option, False) #initialize optional config parameters
|
|
||||||
|
|
||||||
#Open log files
|
Author contact info: Andras Retzler, HA7ILM <randras@sdr.hu>
|
||||||
logs = type("logs_class", (object,), {"access_log":open(cfg.access_log if cfg.access_log else "/dev/null","a"), "error_log":""})()
|
|
||||||
|
|
||||||
#Set signal handler
|
"""
|
||||||
signal.signal(signal.SIGINT, handle_signal) #http://stackoverflow.com/questions/1112343/how-do-i-capture-sigint-in-python
|
)
|
||||||
signal.signal(signal.SIGUSR1, handle_signal)
|
|
||||||
signal.signal(signal.SIGUSR2, handle_signal)
|
|
||||||
|
|
||||||
#Pypy
|
pm = PropertyManager.getSharedInstance().loadConfig("config_webrx")
|
||||||
if pypy: print "pypy detected (and now something completely different: c code is expected to run at a speed of 3*10^8 m/s?)"
|
|
||||||
|
|
||||||
#Change process name to "openwebrx" (to be seen in ps)
|
featureDetector = FeatureDetector()
|
||||||
try:
|
if not featureDetector.is_available("core"):
|
||||||
for libcpath in ["/lib/i386-linux-gnu/libc.so.6","/lib/libc.so.6"]:
|
print(
|
||||||
if os.path.exists(libcpath):
|
"you are missing required dependencies to run openwebrx. "
|
||||||
libc = dl.open(libcpath)
|
"please check that the following core requirements are installed:"
|
||||||
libc.call("prctl", 15, "openwebrx", 0, 0, 0)
|
)
|
||||||
break
|
print(", ".join(featureDetector.get_requirements("core")))
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
#Start rtl thread
|
|
||||||
if os.system("csdr 2> /dev/null") == 32512: #check for csdr
|
|
||||||
print "[openwebrx-main] You need to install \"csdr\" to run OpenWebRX!\n"
|
|
||||||
return
|
return
|
||||||
if os.system("nmux --help 2> /dev/null") == 32512: #check for nmux
|
|
||||||
print "[openwebrx-main] You need to install an up-to-date version of \"csdr\" that contains the \"nmux\" tool to run OpenWebRX! Please upgrade \"csdr\"!\n"
|
|
||||||
return
|
|
||||||
if cfg.start_rtl_thread:
|
|
||||||
nmux_bufcnt = nmux_bufsize = 0
|
|
||||||
while nmux_bufsize < cfg.samp_rate/4: nmux_bufsize += 4096
|
|
||||||
while nmux_bufsize * nmux_bufcnt < cfg.nmux_memory * 1e6: nmux_bufcnt += 1
|
|
||||||
if nmux_bufcnt == 0 or nmux_bufsize == 0:
|
|
||||||
print "[openwebrx-main] Error: nmux_bufsize or nmux_bufcnt is zero. These depend on nmux_memory and samp_rate options in config_webrx.py"
|
|
||||||
return
|
|
||||||
print "[openwebrx-main] nmux_bufsize = %d, nmux_bufcnt = %d" % (nmux_bufsize, nmux_bufcnt)
|
|
||||||
cfg.start_rtl_command += "| nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" % (nmux_bufsize, nmux_bufcnt, cfg.iq_server_port)
|
|
||||||
rtl_thread=threading.Thread(target = lambda:subprocess.Popen(cfg.start_rtl_command, shell=True), args=())
|
|
||||||
rtl_thread.start()
|
|
||||||
print "[openwebrx-main] Started rtl_thread: "+cfg.start_rtl_command
|
|
||||||
print "[openwebrx-main] Waiting for I/Q server to start..."
|
|
||||||
while True:
|
|
||||||
testsock=socket.socket()
|
|
||||||
try: testsock.connect(("127.0.0.1", cfg.iq_server_port))
|
|
||||||
except:
|
|
||||||
time.sleep(0.1)
|
|
||||||
continue
|
|
||||||
testsock.close()
|
|
||||||
break
|
|
||||||
print "[openwebrx-main] I/Q server started."
|
|
||||||
|
|
||||||
#Initialize clients
|
# Get error messages about unknown / unavailable features as soon as possible
|
||||||
clients=[]
|
SdrService.loadProps()
|
||||||
clients_mutex=threading.Lock()
|
|
||||||
lock_try_time=0
|
|
||||||
|
|
||||||
#Start watchdog thread
|
if "sdrhu_key" in pm and pm["sdrhu_public_listing"]:
|
||||||
print "[openwebrx-main] Starting watchdog threads."
|
updater = SdrHuUpdater()
|
||||||
mutex_test_thread=threading.Thread(target = mutex_test_thread_function, args = ())
|
updater.start()
|
||||||
mutex_test_thread.start()
|
|
||||||
mutex_watchdog_thread=threading.Thread(target = mutex_watchdog_thread_function, args = ())
|
Services.start()
|
||||||
mutex_watchdog_thread.start()
|
|
||||||
|
server = ThreadedHttpServer(("0.0.0.0", pm.getPropertyValue("web_port")), RequestHandler)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
#Start spectrum thread
|
if __name__ == "__main__":
|
||||||
print "[openwebrx-main] Starting spectrum thread."
|
|
||||||
spectrum_thread=threading.Thread(target = spectrum_thread_function, args = ())
|
|
||||||
spectrum_thread.start()
|
|
||||||
#spectrum_watchdog_thread=threading.Thread(target = spectrum_watchdog_thread_function, args = ())
|
|
||||||
#spectrum_watchdog_thread.start()
|
|
||||||
|
|
||||||
get_cpu_usage()
|
|
||||||
bcastmsg_thread=threading.Thread(target = bcastmsg_thread_function, args = ())
|
|
||||||
bcastmsg_thread.start()
|
|
||||||
|
|
||||||
#threading.Thread(target = measure_thread_function, args = ()).start()
|
|
||||||
|
|
||||||
#Start sdr.hu update thread
|
|
||||||
if sdrhu and cfg.sdrhu_key and cfg.sdrhu_public_listing:
|
|
||||||
print "[openwebrx-main] Starting sdr.hu update thread..."
|
|
||||||
avatar_ctime=str(os.path.getctime("htdocs/gfx/openwebrx-avatar.png"))
|
|
||||||
sdrhu_thread=threading.Thread(target = sdrhu.run, args = ())
|
|
||||||
sdrhu_thread.start()
|
|
||||||
|
|
||||||
#Start HTTP thread
|
|
||||||
httpd = MultiThreadHTTPServer(('', cfg.web_port), WebRXHandler)
|
|
||||||
print('[openwebrx-main] Starting HTTP server.')
|
|
||||||
access_log("Starting OpenWebRX...")
|
|
||||||
httpd.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
# This is a debug function below:
|
|
||||||
measure_value=0
|
|
||||||
def measure_thread_function():
|
|
||||||
global measure_value
|
|
||||||
while True:
|
|
||||||
print "[openwebrx-measure] value is",measure_value
|
|
||||||
measure_value=0
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
def bcastmsg_thread_function():
|
|
||||||
global clients
|
|
||||||
while True:
|
|
||||||
time.sleep(3)
|
|
||||||
try: cpu_usage=get_cpu_usage()
|
|
||||||
except: cpu_usage=0
|
|
||||||
cma("bcastmsg_thread")
|
|
||||||
for i in range(0,len(clients)):
|
|
||||||
clients[i].bcastmsg="MSG cpu_usage={0} clients={1}".format(int(cpu_usage*100),len(clients))
|
|
||||||
cmr()
|
|
||||||
|
|
||||||
def mutex_test_thread_function():
|
|
||||||
global clients_mutex, lock_try_time
|
|
||||||
while True:
|
|
||||||
time.sleep(0.5)
|
|
||||||
lock_try_time=time.time()
|
|
||||||
clients_mutex.acquire()
|
|
||||||
clients_mutex.release()
|
|
||||||
lock_try_time=0
|
|
||||||
|
|
||||||
def cma(what): #clients_mutex acquire
|
|
||||||
global clients_mutex
|
|
||||||
global clients_mutex_locker
|
|
||||||
if not clients_mutex.locked(): clients_mutex_locker = what
|
|
||||||
clients_mutex.acquire()
|
|
||||||
|
|
||||||
def cmr():
|
|
||||||
global clients_mutex
|
|
||||||
global clients_mutex_locker
|
|
||||||
clients_mutex_locker = None
|
|
||||||
clients_mutex.release()
|
|
||||||
|
|
||||||
def mutex_watchdog_thread_function():
|
|
||||||
global lock_try_time
|
|
||||||
global clients_mutex_locker
|
|
||||||
global clients_mutex
|
|
||||||
while True:
|
|
||||||
if lock_try_time != 0 and time.time()-lock_try_time > 3.0:
|
|
||||||
#if 3 seconds pass without unlock
|
|
||||||
print "[openwebrx-mutex-watchdog] Mutex unlock timeout. Locker: \""+str(clients_mutex_locker)+"\" Now unlocking..."
|
|
||||||
clients_mutex.release()
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
def spectrum_watchdog_thread_function():
|
|
||||||
global spectrum_thread_watchdog_last_tick, receiver_failed
|
|
||||||
while True:
|
|
||||||
time.sleep(60)
|
|
||||||
if spectrum_thread_watchdog_last_tick and time.time()-spectrum_thread_watchdog_last_tick > 60.0:
|
|
||||||
print "[openwebrx-spectrum-watchdog] Spectrum timeout. Seems like no I/Q data is coming from the receiver.\nIf you're using RTL-SDR, the receiver hardware may randomly fail under some circumstances:\n1) high temperature,\n2) insufficient current available from the USB port."
|
|
||||||
print "[openwebrx-spectrum-watchdog] Deactivating receiver."
|
|
||||||
receiver_failed="spectrum"
|
|
||||||
return
|
|
||||||
|
|
||||||
def check_server():
|
|
||||||
global spectrum_dsp, server_fail, rtl_thread
|
|
||||||
if server_fail: return server_fail
|
|
||||||
#print spectrum_dsp.process.poll()
|
|
||||||
if spectrum_dsp and spectrum_dsp.process.poll()!=None: server_fail = "spectrum_thread dsp subprocess failed"
|
|
||||||
#if rtl_thread and not rtl_thread.is_alive(): server_fail = "rtl_thread failed"
|
|
||||||
if server_fail: print "[openwebrx-check_server] >>>>>>> ERROR:", server_fail
|
|
||||||
return server_fail
|
|
||||||
|
|
||||||
def apply_csdr_cfg_to_dsp(dsp):
|
|
||||||
dsp.csdr_dynamic_bufsize = cfg.csdr_dynamic_bufsize
|
|
||||||
dsp.csdr_print_bufsizes = cfg.csdr_print_bufsizes
|
|
||||||
dsp.csdr_through = cfg.csdr_through
|
|
||||||
|
|
||||||
def spectrum_thread_function():
|
|
||||||
global clients, spectrum_dsp, spectrum_thread_watchdog_last_tick
|
|
||||||
spectrum_dsp=dsp=csdr.dsp()
|
|
||||||
dsp.nc_port=cfg.iq_server_port
|
|
||||||
dsp.set_demodulator("fft")
|
|
||||||
dsp.set_samp_rate(cfg.samp_rate)
|
|
||||||
dsp.set_fft_size(cfg.fft_size)
|
|
||||||
dsp.set_fft_fps(cfg.fft_fps)
|
|
||||||
dsp.set_fft_averages(int(round(1.0 * cfg.samp_rate / cfg.fft_size / cfg.fft_fps / (1.0 - cfg.fft_voverlap_factor))) if cfg.fft_voverlap_factor>0 else 0)
|
|
||||||
dsp.set_fft_compression(cfg.fft_compression)
|
|
||||||
dsp.set_format_conversion(cfg.format_conversion)
|
|
||||||
apply_csdr_cfg_to_dsp(dsp)
|
|
||||||
sleep_sec=0.87/cfg.fft_fps
|
|
||||||
print "[openwebrx-spectrum] Spectrum thread initialized successfully."
|
|
||||||
dsp.start()
|
|
||||||
if cfg.csdr_dynamic_bufsize:
|
|
||||||
dsp.read(8) #dummy read to skip bufsize & preamble
|
|
||||||
print "[openwebrx-spectrum] Note: CSDR_DYNAMIC_BUFSIZE_ON = 1"
|
|
||||||
print "[openwebrx-spectrum] Spectrum thread started."
|
|
||||||
bytes_to_read=int(dsp.get_fft_bytes_to_read())
|
|
||||||
spectrum_thread_counter=0
|
|
||||||
while True:
|
|
||||||
data=dsp.read(bytes_to_read)
|
|
||||||
#print "gotcha",len(data),"bytes of spectrum data via spectrum_thread_function()"
|
|
||||||
if spectrum_thread_counter >= cfg.fft_fps:
|
|
||||||
spectrum_thread_counter=0
|
|
||||||
spectrum_thread_watchdog_last_tick = time.time() #once every second
|
|
||||||
else: spectrum_thread_counter+=1
|
|
||||||
cma("spectrum_thread")
|
|
||||||
correction=0
|
|
||||||
for i in range(0,len(clients)):
|
|
||||||
i-=correction
|
|
||||||
if (clients[i].ws_started):
|
|
||||||
if clients[i].spectrum_queue.full():
|
|
||||||
print "[openwebrx-spectrum] client spectrum queue full, closing it."
|
|
||||||
close_client(i, False)
|
|
||||||
correction+=1
|
|
||||||
else:
|
|
||||||
clients[i].spectrum_queue.put([data]) # add new string by "reference" to all clients
|
|
||||||
cmr()
|
|
||||||
|
|
||||||
def get_client_by_id(client_id, use_mutex=True):
|
|
||||||
global clients
|
|
||||||
output=-1
|
|
||||||
if use_mutex: cma("get_client_by_id")
|
|
||||||
for i in range(0,len(clients)):
|
|
||||||
if(clients[i].id==client_id):
|
|
||||||
output=i
|
|
||||||
break
|
|
||||||
if use_mutex: cmr()
|
|
||||||
if output==-1:
|
|
||||||
raise ClientNotFoundException
|
|
||||||
else:
|
|
||||||
return output
|
|
||||||
|
|
||||||
def log_client(client, what):
|
|
||||||
print "[openwebrx-httpd] client {0}#{1} :: {2}".format(client.ip,client.id,what)
|
|
||||||
|
|
||||||
def cleanup_clients(end_all=False):
|
|
||||||
# - if a client doesn't open websocket for too long time, we drop it
|
|
||||||
# - or if end_all is true, we drop all clients
|
|
||||||
global clients
|
|
||||||
cma("cleanup_clients")
|
|
||||||
correction=0
|
|
||||||
for i in range(0,len(clients)):
|
|
||||||
i-=correction
|
|
||||||
#print "cleanup_clients:: len(clients)=", len(clients), "i=", i
|
|
||||||
if end_all or ((not clients[i].ws_started) and (time.time()-clients[i].gen_time)>45):
|
|
||||||
if not end_all: print "[openwebrx] cleanup_clients :: client timeout to open WebSocket"
|
|
||||||
close_client(i, False)
|
|
||||||
correction+=1
|
|
||||||
cmr()
|
|
||||||
|
|
||||||
def generate_client_id(ip):
|
|
||||||
#add a client
|
|
||||||
global clients
|
|
||||||
new_client=namedtuple("ClientStruct", "id gen_time ws_started sprectum_queue ip closed bcastmsg dsp loopstat")
|
|
||||||
new_client.id=md5.md5(str(random.random())).hexdigest()
|
|
||||||
new_client.gen_time=time.time()
|
|
||||||
new_client.ws_started=False # to check whether client has ever tried to open the websocket
|
|
||||||
new_client.spectrum_queue=Queue.Queue(1000)
|
|
||||||
new_client.ip=ip
|
|
||||||
new_client.bcastmsg=""
|
|
||||||
new_client.closed=[False] #byref, not exactly sure if required
|
|
||||||
new_client.dsp=None
|
|
||||||
cma("generate_client_id")
|
|
||||||
clients.append(new_client)
|
|
||||||
log_client(new_client,"client added. Clients now: {0}".format(len(clients)))
|
|
||||||
cmr()
|
|
||||||
cleanup_clients()
|
|
||||||
return new_client.id
|
|
||||||
|
|
||||||
def close_client(i, use_mutex=True):
|
|
||||||
global clients
|
|
||||||
log_client(clients[i],"client being closed.")
|
|
||||||
if use_mutex: cma("close_client")
|
|
||||||
try:
|
try:
|
||||||
clients[i].dsp.stop()
|
main()
|
||||||
except:
|
except KeyboardInterrupt:
|
||||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
WebSocketConnection.closeAll()
|
||||||
print "[openwebrx] close_client dsp.stop() :: error -",exc_type,exc_value
|
|
||||||
traceback.print_tb(exc_traceback)
|
|
||||||
clients[i].closed[0]=True
|
|
||||||
access_log("Stopped streaming to client: "+clients[i].ip+"#"+str(clients[i].id)+" (users now: "+str(len(clients)-1)+")")
|
|
||||||
del clients[i]
|
|
||||||
if use_mutex: cmr()
|
|
||||||
|
|
||||||
# http://www.codeproject.com/Articles/462525/Simple-HTTP-Server-and-Client-in-Python
|
|
||||||
# some ideas are used from the artice above
|
|
||||||
|
|
||||||
class WebRXHandler(BaseHTTPRequestHandler):
|
|
||||||
def proc_read_thread():
|
|
||||||
pass
|
|
||||||
|
|
||||||
def send_302(self,what):
|
|
||||||
self.send_response(302)
|
|
||||||
self.send_header('Content-type','text/html')
|
|
||||||
self.send_header("Location", "http://{0}:{1}/{2}".format(cfg.server_hostname,cfg.web_port,what))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write("<html><body><h1>Object moved</h1>Please <a href=\"/{0}\">click here</a> to continue.</body></html>".format(what))
|
|
||||||
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
||||||
global dsp_plugin, clients_mutex, clients, avatar_ctime, sw_version, receiver_failed
|
|
||||||
rootdir = 'htdocs'
|
|
||||||
self.path=self.path.replace("..","")
|
|
||||||
path_temp_parts=self.path.split("?")
|
|
||||||
self.path=path_temp_parts[0]
|
|
||||||
request_param=path_temp_parts[1] if(len(path_temp_parts)>1) else ""
|
|
||||||
access_log("GET "+self.path+" from "+self.client_address[0])
|
|
||||||
try:
|
|
||||||
if self.path=="/":
|
|
||||||
self.path="/index.wrx"
|
|
||||||
# there's even another cool tip at http://stackoverflow.com/questions/4419650/how-to-implement-timeout-in-basehttpserver-basehttprequesthandler-python
|
|
||||||
#if self.path[:5]=="/lock": cma("do_GET /lock/") # to test mutex_watchdog_thread. Do not uncomment in production environment!
|
|
||||||
if self.path[:4]=="/ws/":
|
|
||||||
print "[openwebrx-ws] Client requested WebSocket connection"
|
|
||||||
if receiver_failed: self.send_error(500,"Internal server error")
|
|
||||||
try:
|
|
||||||
# ========= WebSocket handshake =========
|
|
||||||
ws_success=True
|
|
||||||
try:
|
|
||||||
rxws.handshake(self)
|
|
||||||
cma("do_GET /ws/")
|
|
||||||
client_i=get_client_by_id(self.path[4:], False)
|
|
||||||
myclient=clients[client_i]
|
|
||||||
except rxws.WebSocketException: ws_success=False
|
|
||||||
except ClientNotFoundException: ws_success=False
|
|
||||||
finally:
|
|
||||||
if clients_mutex.locked(): cmr()
|
|
||||||
if not ws_success:
|
|
||||||
self.send_error(400, 'Bad request.')
|
|
||||||
return
|
|
||||||
|
|
||||||
# ========= Client handshake =========
|
|
||||||
if myclient.ws_started:
|
|
||||||
print "[openwebrx-httpd] error: second WS connection with the same client id, throwing it."
|
|
||||||
self.send_error(400, 'Bad request.') #client already started
|
|
||||||
return
|
|
||||||
rxws.send(self, "CLIENT DE SERVER openwebrx.py")
|
|
||||||
client_ans=rxws.recv(self, True)
|
|
||||||
if client_ans[:16]!="SERVER DE CLIENT":
|
|
||||||
rxws.send("ERR Bad answer.")
|
|
||||||
return
|
|
||||||
myclient.ws_started=True
|
|
||||||
#send default parameters
|
|
||||||
rxws.send(self, "MSG center_freq={0} bandwidth={1} fft_size={2} fft_fps={3} audio_compression={4} fft_compression={5} max_clients={6} setup".format(str(cfg.shown_center_freq),str(cfg.samp_rate),cfg.fft_size,cfg.fft_fps,cfg.audio_compression,cfg.fft_compression,cfg.max_clients))
|
|
||||||
|
|
||||||
# ========= Initialize DSP =========
|
|
||||||
dsp=csdr.dsp()
|
|
||||||
dsp_initialized=False
|
|
||||||
dsp.set_audio_compression(cfg.audio_compression)
|
|
||||||
dsp.set_fft_compression(cfg.fft_compression) #used by secondary chains
|
|
||||||
dsp.set_format_conversion(cfg.format_conversion)
|
|
||||||
dsp.set_offset_freq(0)
|
|
||||||
dsp.set_bpf(-4000,4000)
|
|
||||||
dsp.set_secondary_fft_size(cfg.digimodes_fft_size)
|
|
||||||
dsp.nc_port=cfg.iq_server_port
|
|
||||||
apply_csdr_cfg_to_dsp(dsp)
|
|
||||||
myclient.dsp=dsp
|
|
||||||
do_secondary_demod=False
|
|
||||||
access_log("Started streaming to client: "+self.client_address[0]+"#"+myclient.id+" (users now: "+str(len(clients))+")")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
myclient.loopstat=0
|
|
||||||
if myclient.closed[0]:
|
|
||||||
print "[openwebrx-httpd:ws] client closed by other thread"
|
|
||||||
break
|
|
||||||
|
|
||||||
# ========= send audio =========
|
|
||||||
if dsp_initialized:
|
|
||||||
myclient.loopstat=10
|
|
||||||
temp_audio_data=dsp.read(256)
|
|
||||||
myclient.loopstat=11
|
|
||||||
rxws.send(self, temp_audio_data, "AUD ")
|
|
||||||
|
|
||||||
# ========= send spectrum =========
|
|
||||||
while not myclient.spectrum_queue.empty():
|
|
||||||
myclient.loopstat=20
|
|
||||||
spectrum_data=myclient.spectrum_queue.get()
|
|
||||||
#spectrum_data_mid=len(spectrum_data[0])/2
|
|
||||||
#rxws.send(self, spectrum_data[0][spectrum_data_mid:]+spectrum_data[0][:spectrum_data_mid], "FFT ")
|
|
||||||
# (it seems GNU Radio exchanges the first and second part of the FFT output, we correct it)
|
|
||||||
myclient.loopstat=21
|
|
||||||
rxws.send(self, spectrum_data[0],"FFT ")
|
|
||||||
|
|
||||||
# ========= send smeter_level =========
|
|
||||||
smeter_level=None
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
myclient.loopstat=30
|
|
||||||
smeter_level=dsp.get_smeter_level()
|
|
||||||
if smeter_level == None: break
|
|
||||||
except:
|
|
||||||
break
|
|
||||||
if smeter_level!=None:
|
|
||||||
myclient.loopstat=31
|
|
||||||
rxws.send(self, "MSG s={0}".format(smeter_level))
|
|
||||||
|
|
||||||
# ========= send bcastmsg =========
|
|
||||||
if myclient.bcastmsg!="":
|
|
||||||
myclient.loopstat=40
|
|
||||||
rxws.send(self,myclient.bcastmsg)
|
|
||||||
myclient.bcastmsg=""
|
|
||||||
|
|
||||||
# ========= send secondary =========
|
|
||||||
if do_secondary_demod:
|
|
||||||
myclient.loopstat=41
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
secondary_spectrum_data=dsp.read_secondary_fft(dsp.get_secondary_fft_bytes_to_read())
|
|
||||||
if len(secondary_spectrum_data) == 0: break
|
|
||||||
# print "len(secondary_spectrum_data)", len(secondary_spectrum_data) #TODO digimodes
|
|
||||||
rxws.send(self, secondary_spectrum_data, "FFTS")
|
|
||||||
except: break
|
|
||||||
myclient.loopstat=42
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
myclient.loopstat=422
|
|
||||||
secondary_demod_data=dsp.read_secondary_demod(1)
|
|
||||||
myclient.loopstat=423
|
|
||||||
if len(secondary_demod_data) == 0: break
|
|
||||||
# print "len(secondary_demod_data)", len(secondary_demod_data), secondary_demod_data #TODO digimodes
|
|
||||||
rxws.send(self, secondary_demod_data, "DAT ")
|
|
||||||
except: break
|
|
||||||
|
|
||||||
# ========= process commands =========
|
|
||||||
while True:
|
|
||||||
myclient.loopstat=50
|
|
||||||
rdata=rxws.recv(self, False)
|
|
||||||
myclient.loopstat=51
|
|
||||||
#try:
|
|
||||||
if not rdata: break
|
|
||||||
elif rdata[:3]=="SET":
|
|
||||||
print "[openwebrx-httpd:ws,%d] command: %s"%(client_i,rdata)
|
|
||||||
pairs=rdata[4:].split(" ")
|
|
||||||
bpf_set=False
|
|
||||||
new_bpf=dsp.get_bpf()
|
|
||||||
filter_limit=dsp.get_output_rate()/2
|
|
||||||
for pair in pairs:
|
|
||||||
param_name, param_value = pair.split("=")
|
|
||||||
if param_name == "low_cut" and -filter_limit <= int(param_value) <= filter_limit:
|
|
||||||
bpf_set=True
|
|
||||||
new_bpf[0]=int(param_value)
|
|
||||||
elif param_name == "high_cut" and -filter_limit <= int(param_value) <= filter_limit:
|
|
||||||
bpf_set=True
|
|
||||||
new_bpf[1]=int(param_value)
|
|
||||||
elif param_name == "offset_freq" and -cfg.samp_rate/2 <= int(param_value) <= cfg.samp_rate/2:
|
|
||||||
myclient.loopstat=510
|
|
||||||
dsp.set_offset_freq(int(param_value))
|
|
||||||
elif param_name == "squelch_level" and float(param_value) >= 0:
|
|
||||||
myclient.loopstat=520
|
|
||||||
dsp.set_squelch_level(float(param_value))
|
|
||||||
elif param_name=="mod":
|
|
||||||
if (dsp.get_demodulator()!=param_value):
|
|
||||||
myclient.loopstat=530
|
|
||||||
if dsp_initialized: dsp.stop()
|
|
||||||
dsp.set_demodulator(param_value)
|
|
||||||
if dsp_initialized: dsp.start()
|
|
||||||
elif param_name == "output_rate":
|
|
||||||
if not dsp_initialized:
|
|
||||||
myclient.loopstat=540
|
|
||||||
dsp.set_output_rate(int(param_value))
|
|
||||||
myclient.loopstat=541
|
|
||||||
dsp.set_samp_rate(cfg.samp_rate)
|
|
||||||
elif param_name=="action" and param_value=="start":
|
|
||||||
if not dsp_initialized:
|
|
||||||
myclient.loopstat=550
|
|
||||||
dsp.start()
|
|
||||||
dsp_initialized=True
|
|
||||||
elif param_name=="secondary_mod" and cfg.digimodes_enable:
|
|
||||||
if (dsp.get_secondary_demodulator() != param_value):
|
|
||||||
if dsp_initialized: dsp.stop()
|
|
||||||
if param_value == "off":
|
|
||||||
dsp.set_secondary_demodulator(None)
|
|
||||||
do_secondary_demod = False
|
|
||||||
else:
|
|
||||||
dsp.set_secondary_demodulator(param_value)
|
|
||||||
do_secondary_demod = True
|
|
||||||
rxws.send(self, "MSG secondary_fft_size={0} if_samp_rate={1} secondary_bw={2} secondary_setup".format(cfg.digimodes_fft_size, dsp.if_samp_rate(), dsp.secondary_bw()))
|
|
||||||
if dsp_initialized: dsp.start()
|
|
||||||
elif param_name=="secondary_offset_freq" and 0 <= int(param_value) <= dsp.if_samp_rate()/2 and cfg.digimodes_enable:
|
|
||||||
dsp.set_secondary_offset_freq(int(param_value))
|
|
||||||
else:
|
|
||||||
print "[openwebrx-httpd:ws] invalid parameter"
|
|
||||||
if bpf_set:
|
|
||||||
myclient.loopstat=560
|
|
||||||
dsp.set_bpf(*new_bpf)
|
|
||||||
#code.interact(local=locals())
|
|
||||||
except:
|
|
||||||
myclient.loopstat=990
|
|
||||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
||||||
print "[openwebrx-httpd:ws] exception: ",exc_type,exc_value
|
|
||||||
traceback.print_tb(exc_traceback) #TODO digimodes
|
|
||||||
#if exc_value[0]==32: #"broken pipe", client disconnected
|
|
||||||
# pass
|
|
||||||
#elif exc_value[0]==11: #"resource unavailable" on recv, client disconnected
|
|
||||||
# pass
|
|
||||||
#else:
|
|
||||||
# print "[openwebrx-httpd] error in /ws/ handler: ",exc_type,exc_value
|
|
||||||
# traceback.print_tb(exc_traceback)
|
|
||||||
|
|
||||||
#stop dsp for the disconnected client
|
|
||||||
myclient.loopstat=991
|
|
||||||
try:
|
|
||||||
dsp.stop()
|
|
||||||
del dsp
|
|
||||||
except:
|
|
||||||
print "[openwebrx-httpd] error in dsp.stop()"
|
|
||||||
|
|
||||||
#delete disconnected client
|
|
||||||
myclient.loopstat=992
|
|
||||||
try:
|
|
||||||
cma("do_GET /ws/ delete disconnected")
|
|
||||||
id_to_close=get_client_by_id(myclient.id,False)
|
|
||||||
close_client(id_to_close,False)
|
|
||||||
except:
|
|
||||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
||||||
print "[openwebrx-httpd] client cannot be closed: ",exc_type,exc_value
|
|
||||||
traceback.print_tb(exc_traceback)
|
|
||||||
finally:
|
|
||||||
cmr()
|
|
||||||
myclient.loopstat=1000
|
|
||||||
return
|
|
||||||
elif self.path in ("/status", "/status/"):
|
|
||||||
#self.send_header('Content-type','text/plain')
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
getbands=lambda: str(int(cfg.shown_center_freq-cfg.samp_rate/2))+"-"+str(int(cfg.shown_center_freq+cfg.samp_rate/2))
|
|
||||||
self.wfile.write("status="+("inactive" if receiver_failed else "active")+"\nname="+cfg.receiver_name+"\nsdr_hw="+cfg.receiver_device+"\nop_email="+cfg.receiver_admin+"\nbands="+getbands()+"\nusers="+str(len(clients))+"\nusers_max="+str(cfg.max_clients)+"\navatar_ctime="+avatar_ctime+"\ngps="+str(cfg.receiver_gps)+"\nasl="+str(cfg.receiver_asl)+"\nloc="+cfg.receiver_location+"\nsw_version="+sw_version+"\nantenna="+cfg.receiver_ant+"\n")
|
|
||||||
print "[openwebrx-httpd] GET /status/ from",self.client_address[0]
|
|
||||||
else:
|
|
||||||
f=open(rootdir+self.path)
|
|
||||||
data=f.read()
|
|
||||||
extension=self.path[(len(self.path)-4):len(self.path)]
|
|
||||||
extension=extension[2:] if extension[1]=='.' else extension[1:]
|
|
||||||
checkresult=check_server()
|
|
||||||
if extension == "wrx" and (checkresult or receiver_failed):
|
|
||||||
self.send_302("inactive.html")
|
|
||||||
return
|
|
||||||
anyStringsPresentInUserAgent=lambda a: reduce(lambda x,y:x or y, map(lambda b:self.headers['user-agent'].count(b), a), False)
|
|
||||||
if extension == "wrx" and ( (not anyStringsPresentInUserAgent(("Chrome","Firefox","Googlebot","iPhone","iPad","iPod"))) if 'user-agent' in self.headers.keys() else True ) and (not request_param.count("unsupported")):
|
|
||||||
self.send_302("upgrade.html")
|
|
||||||
return
|
|
||||||
if extension == "wrx":
|
|
||||||
cleanup_clients(False)
|
|
||||||
if cfg.max_clients<=len(clients):
|
|
||||||
self.send_302("retry.html")
|
|
||||||
return
|
|
||||||
self.send_response(200)
|
|
||||||
if(("wrx","html","htm").count(extension)):
|
|
||||||
self.send_header('Content-type','text/html')
|
|
||||||
elif(extension=="js"):
|
|
||||||
self.send_header('Content-type','text/javascript')
|
|
||||||
elif(extension=="css"):
|
|
||||||
self.send_header('Content-type','text/css')
|
|
||||||
self.end_headers()
|
|
||||||
if extension == "wrx":
|
|
||||||
replace_dictionary=(
|
|
||||||
("%[RX_PHOTO_DESC]",cfg.photo_desc),
|
|
||||||
("%[CLIENT_ID]", generate_client_id(self.client_address[0])) if "%[CLIENT_ID]" in data else "",
|
|
||||||
("%[WS_URL]","ws://"+cfg.server_hostname+":"+str(cfg.web_port)+"/ws/"),
|
|
||||||
("%[RX_TITLE]",cfg.receiver_name),
|
|
||||||
("%[RX_LOC]",cfg.receiver_location),
|
|
||||||
("%[RX_QRA]",cfg.receiver_qra),
|
|
||||||
("%[RX_ASL]",str(cfg.receiver_asl)),
|
|
||||||
("%[RX_GPS]",str(cfg.receiver_gps[0])+","+str(cfg.receiver_gps[1])),
|
|
||||||
("%[RX_PHOTO_HEIGHT]",str(cfg.photo_height)),("%[RX_PHOTO_TITLE]",cfg.photo_title),
|
|
||||||
("%[RX_ADMIN]",cfg.receiver_admin),
|
|
||||||
("%[RX_ANT]",cfg.receiver_ant),
|
|
||||||
("%[RX_DEVICE]",cfg.receiver_device),
|
|
||||||
("%[AUDIO_BUFSIZE]",str(cfg.client_audio_buffer_size)),
|
|
||||||
("%[START_OFFSET_FREQ]",str(cfg.start_freq-cfg.center_freq)),
|
|
||||||
("%[START_MOD]",cfg.start_mod),
|
|
||||||
("%[WATERFALL_COLORS]",cfg.waterfall_colors),
|
|
||||||
("%[WATERFALL_MIN_LEVEL]",str(cfg.waterfall_min_level)),
|
|
||||||
("%[WATERFALL_MAX_LEVEL]",str(cfg.waterfall_max_level)),
|
|
||||||
("%[WATERFALL_AUTO_LEVEL_MARGIN]","[%d,%d]"%cfg.waterfall_auto_level_margin),
|
|
||||||
("%[DIGIMODES_ENABLE]",("true" if cfg.digimodes_enable else "false")),
|
|
||||||
("%[MATHBOX_WATERFALL_FRES]",str(cfg.mathbox_waterfall_frequency_resolution)),
|
|
||||||
("%[MATHBOX_WATERFALL_THIST]",str(cfg.mathbox_waterfall_history_length)),
|
|
||||||
("%[MATHBOX_WATERFALL_COLORS]",cfg.mathbox_waterfall_colors)
|
|
||||||
)
|
|
||||||
for rule in replace_dictionary:
|
|
||||||
while data.find(rule[0])!=-1:
|
|
||||||
data=data.replace(rule[0],rule[1])
|
|
||||||
self.wfile.write(data)
|
|
||||||
f.close()
|
|
||||||
return
|
|
||||||
except IOError:
|
|
||||||
self.send_error(404, 'Invalid path.')
|
|
||||||
except:
|
|
||||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
||||||
print "[openwebrx-httpd] error (@outside):", exc_type, exc_value
|
|
||||||
traceback.print_tb(exc_traceback)
|
|
||||||
|
|
||||||
|
|
||||||
class ClientNotFoundException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
last_worktime=0
|
|
||||||
last_idletime=0
|
|
||||||
|
|
||||||
def get_cpu_usage():
|
|
||||||
global last_worktime, last_idletime
|
|
||||||
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-last_worktime)
|
|
||||||
didletime=(idletime-last_idletime)
|
|
||||||
rate=float(dworktime)/(didletime+dworktime)
|
|
||||||
last_worktime=worktime
|
|
||||||
last_idletime=idletime
|
|
||||||
if(last_worktime==0): return 0
|
|
||||||
return rate
|
|
||||||
|
|
||||||
|
|
||||||
if __name__=="__main__":
|
|
||||||
main()
|
|
||||||
|
|||||||
577
owrx/aprs.py
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
from owrx.kiss import KissDeframer
|
||||||
|
from owrx.map import Map, LatLngLocation
|
||||||
|
from owrx.bands import Bandplan
|
||||||
|
from owrx.metrics import Metrics, CounterMetric
|
||||||
|
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})$")
|
||||||
|
|
||||||
|
|
||||||
|
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(object):
|
||||||
|
def __init__(self, handler):
|
||||||
|
self.ax25parser = Ax25Parser()
|
||||||
|
self.deframer = KissDeframer()
|
||||||
|
self.dial_freq = None
|
||||||
|
self.band = None
|
||||||
|
self.handler = handler
|
||||||
|
self.metric = self.getMetric()
|
||||||
|
|
||||||
|
def setDialFrequency(self, freq):
|
||||||
|
self.dial_freq = freq
|
||||||
|
self.band = Bandplan.getSharedInstance().findBand(freq)
|
||||||
|
self.metric = self.getMetric()
|
||||||
|
|
||||||
|
def getMetric(self):
|
||||||
|
band = "unknown"
|
||||||
|
if self.band is not None:
|
||||||
|
band = self.band.getName()
|
||||||
|
name = "aprs.decodes.{band}.aprs".format(band=band)
|
||||||
|
metrics = Metrics.getSharedInstance()
|
||||||
|
metric = metrics.getMetric(name)
|
||||||
|
if metric is None:
|
||||||
|
metric = CounterMetric()
|
||||||
|
metrics.addMetric(name, metric)
|
||||||
|
return metric
|
||||||
|
|
||||||
|
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.metric.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])),
|
||||||
|
}
|
||||||
65
owrx/bands.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
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:
|
||||||
|
for (mode, freqs) in dict["frequencies"].items():
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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):
|
||||||
|
f = open("bands.json", "r")
|
||||||
|
bands_json = json.load(f)
|
||||||
|
f.close()
|
||||||
|
self.bands = [Band(d) for d in bands_json]
|
||||||
|
|
||||||
|
def findBands(self, freq):
|
||||||
|
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):
|
||||||
|
return [e for b in self.bands for e in b.getDialFrequencies(range)]
|
||||||
43
owrx/bookmarks.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
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 Bookmarks(object):
|
||||||
|
sharedInstance = None
|
||||||
|
@staticmethod
|
||||||
|
def getSharedInstance():
|
||||||
|
if Bookmarks.sharedInstance is None:
|
||||||
|
Bookmarks.sharedInstance = Bookmarks()
|
||||||
|
return Bookmarks.sharedInstance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
f = open("bookmarks.json", "r")
|
||||||
|
bookmarks_json = json.load(f)
|
||||||
|
f.close()
|
||||||
|
self.bookmarks = [Bookmark(d) for d in bookmarks_json]
|
||||||
|
|
||||||
|
def getBookmarks(self, range):
|
||||||
|
(lo, hi) = range
|
||||||
|
return [b for b in self.bookmarks if lo <= b.getFrequency() <= hi]
|
||||||
137
owrx/config.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription(object):
|
||||||
|
def __init__(self, subscriptee, subscriber):
|
||||||
|
self.subscriptee = subscriptee
|
||||||
|
self.subscriber = subscriber
|
||||||
|
|
||||||
|
def call(self, *args, **kwargs):
|
||||||
|
self.subscriber(*args, **kwargs)
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self.subscriptee.unwire(self)
|
||||||
|
|
||||||
|
|
||||||
|
class Property(object):
|
||||||
|
def __init__(self, value=None):
|
||||||
|
self.value = value
|
||||||
|
self.subscribers = []
|
||||||
|
|
||||||
|
def getValue(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def setValue(self, value):
|
||||||
|
if self.value == value:
|
||||||
|
return self
|
||||||
|
self.value = value
|
||||||
|
for c in self.subscribers:
|
||||||
|
try:
|
||||||
|
c.call(self.value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def wire(self, callback):
|
||||||
|
sub = Subscription(self, callback)
|
||||||
|
self.subscribers.append(sub)
|
||||||
|
if not self.value is None:
|
||||||
|
sub.call(self.value)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
def unwire(self, sub):
|
||||||
|
try:
|
||||||
|
self.subscribers.remove(sub)
|
||||||
|
except ValueError:
|
||||||
|
# happens when already removed before
|
||||||
|
pass
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyManager(object):
|
||||||
|
sharedInstance = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getSharedInstance():
|
||||||
|
if PropertyManager.sharedInstance is None:
|
||||||
|
PropertyManager.sharedInstance = PropertyManager()
|
||||||
|
return PropertyManager.sharedInstance
|
||||||
|
|
||||||
|
def collect(self, *props):
|
||||||
|
return PropertyManager(
|
||||||
|
{name: self.getProperty(name) if self.hasProperty(name) else Property() for name in props}
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, properties=None):
|
||||||
|
self.properties = {}
|
||||||
|
self.subscribers = []
|
||||||
|
if properties is not None:
|
||||||
|
for (name, prop) in properties.items():
|
||||||
|
self.add(name, prop)
|
||||||
|
|
||||||
|
def add(self, name, prop):
|
||||||
|
self.properties[name] = prop
|
||||||
|
|
||||||
|
def fireCallbacks(value):
|
||||||
|
for c in self.subscribers:
|
||||||
|
try:
|
||||||
|
c.call(name, value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
|
||||||
|
prop.wire(fireCallbacks)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __contains__(self, name):
|
||||||
|
return self.hasProperty(name)
|
||||||
|
|
||||||
|
def __getitem__(self, name):
|
||||||
|
return self.getPropertyValue(name)
|
||||||
|
|
||||||
|
def __setitem__(self, name, value):
|
||||||
|
if not self.hasProperty(name):
|
||||||
|
self.add(name, Property())
|
||||||
|
self.getProperty(name).setValue(value)
|
||||||
|
|
||||||
|
def __dict__(self):
|
||||||
|
return {k: v.getValue() for k, v in self.properties.items()}
|
||||||
|
|
||||||
|
def hasProperty(self, name):
|
||||||
|
return name in self.properties
|
||||||
|
|
||||||
|
def getProperty(self, name):
|
||||||
|
if not self.hasProperty(name):
|
||||||
|
self.add(name, Property())
|
||||||
|
return self.properties[name]
|
||||||
|
|
||||||
|
def getPropertyValue(self, name):
|
||||||
|
return self.getProperty(name).getValue()
|
||||||
|
|
||||||
|
def wire(self, callback):
|
||||||
|
sub = Subscription(self, callback)
|
||||||
|
self.subscribers.append(sub)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
def unwire(self, sub):
|
||||||
|
try:
|
||||||
|
self.subscribers.remove(sub)
|
||||||
|
except ValueError:
|
||||||
|
# happens when already removed before
|
||||||
|
pass
|
||||||
|
return self
|
||||||
|
|
||||||
|
def defaults(self, other_pm):
|
||||||
|
for (key, p) in self.properties.items():
|
||||||
|
if p.getValue() is None:
|
||||||
|
p.setValue(other_pm[key])
|
||||||
|
return self
|
||||||
|
|
||||||
|
def loadConfig(self, filename):
|
||||||
|
cfg = __import__(filename)
|
||||||
|
for name, value in cfg.__dict__.items():
|
||||||
|
if name.startswith("__"):
|
||||||
|
continue
|
||||||
|
self[name] = value
|
||||||
|
return self
|
||||||
324
owrx/connection.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
from owrx.config import PropertyManager
|
||||||
|
from owrx.source import DspManager, CpuUsageThread, SdrService, ClientRegistry
|
||||||
|
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 multiprocessing import Queue
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Client(object):
|
||||||
|
def __init__(self, conn):
|
||||||
|
self.conn = conn
|
||||||
|
self.multiprocessingPipe = Queue()
|
||||||
|
|
||||||
|
def mp_passthru():
|
||||||
|
run = True
|
||||||
|
while run:
|
||||||
|
try:
|
||||||
|
data = self.multiprocessingPipe.get()
|
||||||
|
self.send(data)
|
||||||
|
except (EOFError, OSError):
|
||||||
|
run = False
|
||||||
|
|
||||||
|
threading.Thread(target=mp_passthru).start()
|
||||||
|
|
||||||
|
def send(self, data):
|
||||||
|
self.conn.send(data)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.conn.close()
|
||||||
|
self.multiprocessingPipe.close()
|
||||||
|
|
||||||
|
def mp_send(self, data):
|
||||||
|
self.multiprocessingPipe.put(data, block=False)
|
||||||
|
|
||||||
|
def handleTextMessage(self, conn, message):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handleBinaryMessage(self, conn, data):
|
||||||
|
logger.error("unsupported binary message, discarding")
|
||||||
|
|
||||||
|
def handleClose(self):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class OpenWebRxReceiverClient(Client):
|
||||||
|
config_keys = [
|
||||||
|
"waterfall_colors",
|
||||||
|
"waterfall_min_level",
|
||||||
|
"waterfall_max_level",
|
||||||
|
"waterfall_auto_level_margin",
|
||||||
|
"lfo_offset",
|
||||||
|
"samp_rate",
|
||||||
|
"fft_size",
|
||||||
|
"fft_fps",
|
||||||
|
"audio_compression",
|
||||||
|
"fft_compression",
|
||||||
|
"max_clients",
|
||||||
|
"start_mod",
|
||||||
|
"client_audio_buffer_size",
|
||||||
|
"start_freq",
|
||||||
|
"center_freq",
|
||||||
|
"mathbox_waterfall_colors",
|
||||||
|
"mathbox_waterfall_history_length",
|
||||||
|
"mathbox_waterfall_frequency_resolution",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, conn):
|
||||||
|
super().__init__(conn)
|
||||||
|
|
||||||
|
self.dsp = None
|
||||||
|
self.sdr = None
|
||||||
|
self.configSub = None
|
||||||
|
|
||||||
|
ClientRegistry.getSharedInstance().addClient(self)
|
||||||
|
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
|
||||||
|
self.setSdr()
|
||||||
|
|
||||||
|
# send receiver info
|
||||||
|
receiver_keys = [
|
||||||
|
"receiver_name",
|
||||||
|
"receiver_location",
|
||||||
|
"receiver_qra",
|
||||||
|
"receiver_asl",
|
||||||
|
"receiver_gps",
|
||||||
|
"photo_title",
|
||||||
|
"photo_desc",
|
||||||
|
]
|
||||||
|
receiver_details = dict((key, pm.getPropertyValue(key)) for key in receiver_keys)
|
||||||
|
self.write_receiver_details(receiver_details)
|
||||||
|
|
||||||
|
profiles = [
|
||||||
|
{"name": s.getName() + " " + p["name"], "id": sid + "|" + pid}
|
||||||
|
for (sid, s) in SdrService.getSources().items()
|
||||||
|
for (pid, p) in s.getProfiles().items()
|
||||||
|
]
|
||||||
|
self.write_profiles(profiles)
|
||||||
|
|
||||||
|
features = FeatureDetector().feature_availability()
|
||||||
|
self.write_features(features)
|
||||||
|
|
||||||
|
CpuUsageThread.getSharedInstance().add_client(self)
|
||||||
|
|
||||||
|
def handleTextMessage(self, conn, message):
|
||||||
|
try:
|
||||||
|
message = json.loads(message)
|
||||||
|
if "type" in message:
|
||||||
|
if message["type"] == "dspcontrol":
|
||||||
|
if "action" in message and message["action"] == "start":
|
||||||
|
self.startDsp()
|
||||||
|
|
||||||
|
if "params" in message:
|
||||||
|
params = message["params"]
|
||||||
|
self.setDspProperties(params)
|
||||||
|
|
||||||
|
if message["type"] == "config":
|
||||||
|
if "params" in message:
|
||||||
|
self.setParams(message["params"])
|
||||||
|
if message["type"] == "setsdr":
|
||||||
|
if "params" in message:
|
||||||
|
self.setSdr(message["params"]["sdr"])
|
||||||
|
if 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])
|
||||||
|
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 = SdrService.getSource(id)
|
||||||
|
if next == self.sdr:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stopDsp()
|
||||||
|
|
||||||
|
if self.configSub is not None:
|
||||||
|
self.configSub.cancel()
|
||||||
|
self.configSub = None
|
||||||
|
|
||||||
|
self.sdr = next
|
||||||
|
|
||||||
|
# send initial config
|
||||||
|
configProps = (
|
||||||
|
self.sdr.getProps()
|
||||||
|
.collect(*OpenWebRxReceiverClient.config_keys)
|
||||||
|
.defaults(PropertyManager.getSharedInstance())
|
||||||
|
)
|
||||||
|
|
||||||
|
def sendConfig(key, value):
|
||||||
|
config = dict((key, configProps[key]) for key in OpenWebRxReceiverClient.config_keys)
|
||||||
|
# TODO mathematical properties? hmmmm
|
||||||
|
config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"]
|
||||||
|
self.write_config(config)
|
||||||
|
|
||||||
|
cf = configProps["center_freq"]
|
||||||
|
srh = configProps["samp_rate"] / 2
|
||||||
|
frequencyRange = (cf - srh, cf + srh)
|
||||||
|
self.write_dial_frequendies(Bandplan.getSharedInstance().collectDialFrequencies(frequencyRange))
|
||||||
|
bookmarks = [b.__dict__() for b in Bookmarks.getSharedInstance().getBookmarks(frequencyRange)]
|
||||||
|
self.write_bookmarks(bookmarks)
|
||||||
|
|
||||||
|
self.configSub = configProps.wire(sendConfig)
|
||||||
|
sendConfig(None, None)
|
||||||
|
|
||||||
|
self.sdr.addSpectrumClient(self)
|
||||||
|
|
||||||
|
def startDsp(self):
|
||||||
|
if self.dsp is None:
|
||||||
|
self.dsp = DspManager(self, self.sdr)
|
||||||
|
self.dsp.start()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.stopDsp()
|
||||||
|
CpuUsageThread.getSharedInstance().remove_client(self)
|
||||||
|
ClientRegistry.getSharedInstance().removeClient(self)
|
||||||
|
if self.configSub is not None:
|
||||||
|
self.configSub.cancel()
|
||||||
|
self.configSub = None
|
||||||
|
super().close()
|
||||||
|
|
||||||
|
def stopDsp(self):
|
||||||
|
if self.dsp is not None:
|
||||||
|
self.dsp.stop()
|
||||||
|
self.dsp = None
|
||||||
|
if self.sdr is not None:
|
||||||
|
self.sdr.removeSpectrumClient(self)
|
||||||
|
|
||||||
|
def setParams(self, params):
|
||||||
|
# only the keys in the protected property manager can be overridden from the web
|
||||||
|
protected = (
|
||||||
|
self.sdr.getProps()
|
||||||
|
.collect("samp_rate", "center_freq", "rf_gain", "type", "if_gain")
|
||||||
|
.defaults(PropertyManager.getSharedInstance())
|
||||||
|
)
|
||||||
|
for key, value in params.items():
|
||||||
|
protected[key] = value
|
||||||
|
|
||||||
|
def setDspProperties(self, params):
|
||||||
|
for key, value in params.items():
|
||||||
|
self.dsp.setProperty(key, value)
|
||||||
|
|
||||||
|
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_s_meter_level(self, level):
|
||||||
|
self.send({"type": "smeter", "value": 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):
|
||||||
|
self.send(bytes([0x04]) + data)
|
||||||
|
|
||||||
|
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_receiver_details(self, details):
|
||||||
|
self.send({"type": "receiver_details", "value": details})
|
||||||
|
|
||||||
|
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_frequendies(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})
|
||||||
|
|
||||||
|
|
||||||
|
class MapConnection(Client):
|
||||||
|
def __init__(self, conn):
|
||||||
|
super().__init__(conn)
|
||||||
|
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
self.write_config(pm.collect("google_maps_api_key", "receiver_gps", "map_position_retention_time").__dict__())
|
||||||
|
|
||||||
|
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 WebSocketMessageHandler(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.handshake = None
|
||||||
|
|
||||||
|
def handleTextMessage(self, conn, message):
|
||||||
|
if message[:16] == "SERVER DE CLIENT":
|
||||||
|
meta = message[17:].split(" ")
|
||||||
|
self.handshake = {v[0]: "=".join(v[1:]) for v in map(lambda x: x.split("="), meta)}
|
||||||
|
|
||||||
|
conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version=openwebrx_version))
|
||||||
|
logger.debug("client connection intitialized")
|
||||||
|
|
||||||
|
if "type" in self.handshake:
|
||||||
|
if self.handshake["type"] == "receiver":
|
||||||
|
client = OpenWebRxReceiverClient(conn)
|
||||||
|
if self.handshake["type"] == "map":
|
||||||
|
client = MapConnection(conn)
|
||||||
|
# backwards compatibility
|
||||||
|
else:
|
||||||
|
client = OpenWebRxReceiverClient(conn)
|
||||||
|
|
||||||
|
# hand off all further communication to the correspondig connection
|
||||||
|
conn.setMessageHandler(client)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.handshake:
|
||||||
|
logger.warning("not answering client request since handshake is not complete")
|
||||||
|
return
|
||||||
|
|
||||||
|
def handleBinaryMessage(self, conn, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handleClose(self):
|
||||||
|
pass
|
||||||
155
owrx/controllers.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import os
|
||||||
|
import mimetypes
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from string import Template
|
||||||
|
from owrx.websocket import WebSocketConnection
|
||||||
|
from owrx.config import PropertyManager
|
||||||
|
from owrx.source import ClientRegistry
|
||||||
|
from owrx.connection import WebSocketMessageHandler
|
||||||
|
from owrx.version import openwebrx_version
|
||||||
|
from owrx.feature import FeatureDetector
|
||||||
|
from owrx.metrics import Metrics
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
def __init__(self, handler, request):
|
||||||
|
self.handler = handler
|
||||||
|
self.request = request
|
||||||
|
|
||||||
|
def send_response(self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None):
|
||||||
|
self.handler.send_response(code)
|
||||||
|
if content_type is not None:
|
||||||
|
self.handler.send_header("Content-Type", content_type)
|
||||||
|
if last_modified is not None:
|
||||||
|
self.handler.send_header("Last-Modified", last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT"))
|
||||||
|
if max_age is not None:
|
||||||
|
self.handler.send_header("Cache-Control", "max-age: {0}".format(max_age))
|
||||||
|
self.handler.end_headers()
|
||||||
|
if type(content) == str:
|
||||||
|
content = content.encode()
|
||||||
|
self.handler.wfile.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusController(Controller):
|
||||||
|
def handle_request(self):
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
# TODO keys that have been left out since they are no longer simple strings: sdr_hw, bands, antenna
|
||||||
|
vars = {
|
||||||
|
"status": "active",
|
||||||
|
"name": pm["receiver_name"],
|
||||||
|
"op_email": pm["receiver_admin"],
|
||||||
|
"users": ClientRegistry.getSharedInstance().clientCount(),
|
||||||
|
"users_max": pm["max_clients"],
|
||||||
|
"gps": pm["receiver_gps"],
|
||||||
|
"asl": pm["receiver_asl"],
|
||||||
|
"loc": pm["receiver_location"],
|
||||||
|
"sw_version": openwebrx_version,
|
||||||
|
"avatar_ctime": os.path.getctime("htdocs/gfx/openwebrx-avatar.png"),
|
||||||
|
}
|
||||||
|
self.send_response("\n".join(["{key}={value}".format(key=key, value=value) for key, value in vars.items()]))
|
||||||
|
|
||||||
|
|
||||||
|
class AssetsController(Controller):
|
||||||
|
def __init__(self, handler, request, path):
|
||||||
|
if not path.endswith("/"):
|
||||||
|
path += "/"
|
||||||
|
self.path = path
|
||||||
|
super().__init__(handler, request)
|
||||||
|
|
||||||
|
def serve_file(self, file, content_type=None):
|
||||||
|
try:
|
||||||
|
modified = datetime.fromtimestamp(os.path.getmtime(self.path + file))
|
||||||
|
|
||||||
|
if "If-Modified-Since" in self.handler.headers:
|
||||||
|
client_modified = datetime.strptime(
|
||||||
|
self.handler.headers["If-Modified-Since"], "%a, %d %b %Y %H:%M:%S %Z"
|
||||||
|
)
|
||||||
|
if modified <= client_modified:
|
||||||
|
self.send_response("", code=304)
|
||||||
|
return
|
||||||
|
|
||||||
|
f = open(self.path + file, "rb")
|
||||||
|
data = f.read()
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
if content_type is None:
|
||||||
|
(content_type, encoding) = mimetypes.MimeTypes().guess_type(file)
|
||||||
|
self.send_response(data, content_type=content_type, last_modified=modified, max_age=3600)
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.send_response("file not found", code=404)
|
||||||
|
|
||||||
|
def handle_request(self):
|
||||||
|
filename = self.request.matches.group(1)
|
||||||
|
self.serve_file(filename)
|
||||||
|
|
||||||
|
|
||||||
|
class OwrxAssetsController(AssetsController):
|
||||||
|
def __init__(self, handler, request):
|
||||||
|
super().__init__(handler, request, "htdocs/")
|
||||||
|
|
||||||
|
|
||||||
|
class AprsSymbolsController(AssetsController):
|
||||||
|
def __init__(self, handler, request):
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
super().__init__(handler, request, pm["aprs_symbols_path"])
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateController(Controller):
|
||||||
|
def render_template(self, file, **vars):
|
||||||
|
f = open("htdocs/" + file, "r")
|
||||||
|
template = Template(f.read())
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
return template.safe_substitute(**vars)
|
||||||
|
|
||||||
|
def serve_template(self, file, **vars):
|
||||||
|
self.send_response(self.render_template(file, **vars), content_type="text/html")
|
||||||
|
|
||||||
|
def default_variables(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class WebpageController(TemplateController):
|
||||||
|
def template_variables(self):
|
||||||
|
header = self.render_template("include/header.include.html")
|
||||||
|
return {"header": header}
|
||||||
|
|
||||||
|
|
||||||
|
class IndexController(WebpageController):
|
||||||
|
def handle_request(self):
|
||||||
|
self.serve_template("index.html", **self.template_variables())
|
||||||
|
|
||||||
|
|
||||||
|
class MapController(WebpageController):
|
||||||
|
def handle_request(self):
|
||||||
|
# TODO check if we have a google maps api key first?
|
||||||
|
self.serve_template("map.html", **self.template_variables())
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureController(WebpageController):
|
||||||
|
def handle_request(self):
|
||||||
|
self.serve_template("features.html", **self.template_variables())
|
||||||
|
|
||||||
|
|
||||||
|
class ApiController(Controller):
|
||||||
|
def handle_request(self):
|
||||||
|
data = json.dumps(FeatureDetector().feature_report())
|
||||||
|
self.send_response(data, content_type="application/json")
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsController(Controller):
|
||||||
|
def handle_request(self):
|
||||||
|
data = json.dumps(Metrics.getSharedInstance().getMetrics())
|
||||||
|
self.send_response(data, content_type="application/json")
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketController(Controller):
|
||||||
|
def handle_request(self):
|
||||||
|
conn = WebSocketConnection(self.handler, WebSocketMessageHandler())
|
||||||
|
# enter read loop
|
||||||
|
conn.read_loop()
|
||||||
219
owrx/feature.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from functools import reduce
|
||||||
|
from operator import and_
|
||||||
|
import re
|
||||||
|
from distutils.version import LooseVersion
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownFeatureException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureDetector(object):
|
||||||
|
features = {
|
||||||
|
"core": ["csdr", "nmux", "nc"],
|
||||||
|
"rtl_sdr": ["rtl_sdr"],
|
||||||
|
"sdrplay": ["rx_tools"],
|
||||||
|
"hackrf": ["hackrf_transfer"],
|
||||||
|
"airspy": ["airspy_rx"],
|
||||||
|
"digital_voice_digiham": ["digiham", "sox"],
|
||||||
|
"digital_voice_dsd": ["dsd", "sox", "digiham"],
|
||||||
|
"wsjt-x": ["wsjtx", "sox"],
|
||||||
|
"packet": ["direwolf"],
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
"description": "",
|
||||||
|
"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):
|
||||||
|
method = self._get_requirement_method(requirement)
|
||||||
|
if method is not None:
|
||||||
|
return method()
|
||||||
|
else:
|
||||||
|
logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_requirement_description(self, requirement):
|
||||||
|
return inspect.getdoc(self._get_requirement_method(requirement))
|
||||||
|
|
||||||
|
def command_is_runnable(self, command):
|
||||||
|
return os.system("{0} 2>/dev/null >/dev/null".format(command)) != 32512
|
||||||
|
|
||||||
|
def has_csdr(self):
|
||||||
|
"""
|
||||||
|
OpenWebRX uses the demodulator and pipeline tools provided by the csdr project. Please check out [the project
|
||||||
|
page on github](https://github.com/simonyiszk/csdr) for further details and installation instructions.
|
||||||
|
"""
|
||||||
|
return self.command_is_runnable("csdr")
|
||||||
|
|
||||||
|
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_rtl_sdr(self):
|
||||||
|
"""
|
||||||
|
The rtl-sdr command is required to read I/Q data from an RTL SDR USB-Stick. It is available in most
|
||||||
|
distribution package managers.
|
||||||
|
"""
|
||||||
|
return self.command_is_runnable("rtl_sdr --help")
|
||||||
|
|
||||||
|
def has_rx_tools(self):
|
||||||
|
"""
|
||||||
|
The rx_tools package can be used to interface with SDR devices compatible with SoapySDR. It is currently used
|
||||||
|
to connect to SDRPlay devices. Please check the following pages for more details:
|
||||||
|
|
||||||
|
* [rx_tools GitHub page](https://github.com/rxseger/rx_tools)
|
||||||
|
* [SoapySDR Project wiki](https://github.com/pothosware/SoapySDR/wiki)
|
||||||
|
* [SDRPlay homepage](https://www.sdrplay.com/)
|
||||||
|
"""
|
||||||
|
return self.command_is_runnable("rx_sdr --help")
|
||||||
|
|
||||||
|
def has_hackrf_transfer(self):
|
||||||
|
"""
|
||||||
|
To use a HackRF, compile the HackRF host tools from its "stdout" branch:
|
||||||
|
```
|
||||||
|
git clone https://github.com/mossmann/hackrf/
|
||||||
|
cd hackrf
|
||||||
|
git fetch
|
||||||
|
git checkout origin/stdout
|
||||||
|
cd host
|
||||||
|
mkdir build
|
||||||
|
cd build
|
||||||
|
cmake .. -DINSTALL_UDEV_RULES=ON
|
||||||
|
make
|
||||||
|
sudo make install
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
# TODO i don't have a hackrf, so somebody doublecheck this.
|
||||||
|
# TODO also check if it has the stdout feature
|
||||||
|
return self.command_is_runnable("hackrf_transfer --help")
|
||||||
|
|
||||||
|
def command_exists(self, command):
|
||||||
|
return os.system("which {0}".format(command)) == 0
|
||||||
|
|
||||||
|
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.2 of digiham.
|
||||||
|
"""
|
||||||
|
required_version = LooseVersion("0.2")
|
||||||
|
|
||||||
|
digiham_version_regex = re.compile("^digiham version (.*)$")
|
||||||
|
|
||||||
|
def check_digiham_version(command):
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE)
|
||||||
|
version = LooseVersion(digiham_version_regex.match(process.stdout.readline().decode()).group(1))
|
||||||
|
process.wait(1)
|
||||||
|
return 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",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_dsd(self):
|
||||||
|
"""
|
||||||
|
The digital voice modes NXDN and D-Star can be decoded by the dsd project. Please note that you need the version
|
||||||
|
modified by F4EXB that provides stdin/stdout support. You can find it [here](https://github.com/f4exb/dsd).
|
||||||
|
"""
|
||||||
|
return self.command_is_runnable("dsd")
|
||||||
|
|
||||||
|
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):
|
||||||
|
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 2> /dev/null")
|
||||||
|
|
||||||
|
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)
|
||||||
76
owrx/http.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from owrx.controllers import (
|
||||||
|
StatusController,
|
||||||
|
IndexController,
|
||||||
|
OwrxAssetsController,
|
||||||
|
WebSocketController,
|
||||||
|
MapController,
|
||||||
|
FeatureController,
|
||||||
|
ApiController,
|
||||||
|
MetricsController,
|
||||||
|
AprsSymbolsController,
|
||||||
|
)
|
||||||
|
from http.server import BaseHTTPRequestHandler
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
import re
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestHandler(BaseHTTPRequestHandler):
|
||||||
|
def __init__(self, request, client_address, server):
|
||||||
|
self.router = Router()
|
||||||
|
super().__init__(request, client_address, server)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class Request(object):
|
||||||
|
def __init__(self, query=None, matches=None):
|
||||||
|
self.query = query
|
||||||
|
self.matches = matches
|
||||||
|
|
||||||
|
|
||||||
|
class Router(object):
|
||||||
|
mappings = [
|
||||||
|
{"route": "/", "controller": IndexController},
|
||||||
|
{"route": "/status", "controller": StatusController},
|
||||||
|
{"regex": "/static/(.+)", "controller": OwrxAssetsController},
|
||||||
|
{"regex": "/aprs-symbols/(.+)", "controller": AprsSymbolsController},
|
||||||
|
{"route": "/ws/", "controller": WebSocketController},
|
||||||
|
{"regex": "(/favicon.ico)", "controller": OwrxAssetsController},
|
||||||
|
# backwards compatibility for the sdr.hu portal
|
||||||
|
{"regex": "/(gfx/openwebrx-avatar.png)", "controller": OwrxAssetsController},
|
||||||
|
{"route": "/map", "controller": MapController},
|
||||||
|
{"route": "/features", "controller": FeatureController},
|
||||||
|
{"route": "/api/features", "controller": ApiController},
|
||||||
|
{"route": "/metrics", "controller": MetricsController},
|
||||||
|
]
|
||||||
|
|
||||||
|
def find_controller(self, path):
|
||||||
|
for m in Router.mappings:
|
||||||
|
if "route" in m:
|
||||||
|
if m["route"] == path:
|
||||||
|
return (m["controller"], None)
|
||||||
|
if "regex" in m:
|
||||||
|
regex = re.compile(m["regex"])
|
||||||
|
matches = regex.match(path)
|
||||||
|
if matches:
|
||||||
|
return (m["controller"], matches)
|
||||||
|
|
||||||
|
def route(self, handler):
|
||||||
|
url = urlparse(handler.path)
|
||||||
|
res = self.find_controller(url.path)
|
||||||
|
if res is not None:
|
||||||
|
(controller, matches) = res
|
||||||
|
query = parse_qs(url.query)
|
||||||
|
request = Request(query, matches)
|
||||||
|
controller(handler, request).handle_request()
|
||||||
|
else:
|
||||||
|
handler.send_error(404, "Not Found", "The page you requested could not be found.")
|
||||||
104
owrx/kiss.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from owrx.config import PropertyManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FEND = 0xC0
|
||||||
|
FESC = 0xDB
|
||||||
|
TFEND = 0xDC
|
||||||
|
TFESC = 0xDD
|
||||||
|
|
||||||
|
|
||||||
|
class DirewolfConfig(object):
|
||||||
|
def getConfig(self, port, is_service):
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
|
||||||
|
config = """
|
||||||
|
ACHANNELS 1
|
||||||
|
|
||||||
|
CHANNEL 0
|
||||||
|
MYCALL {callsign}
|
||||||
|
MODEM 1200
|
||||||
|
|
||||||
|
KISSPORT {port}
|
||||||
|
AGWPORT off
|
||||||
|
""".format(
|
||||||
|
port=port, callsign=pm["aprs_callsign"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_service and pm["aprs_igate_enabled"]:
|
||||||
|
config += """
|
||||||
|
IGSERVER {server}
|
||||||
|
IGLOGIN {callsign} {password}
|
||||||
|
""".format(
|
||||||
|
server=pm["aprs_igate_server"], callsign=pm["aprs_callsign"], password=pm["aprs_igate_password"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if pm["aprs_igate_beacon"]:
|
||||||
|
(lat, lon) = pm["receiver_gps"]
|
||||||
|
lat = "{0}^{1:.2f}{2}".format(int(lat), (lat - int(lat)) * 60, "N" if lat > 0 else "S")
|
||||||
|
lon = "{0}^{1:.2f}{2}".format(int(lon), (lon - int(lon)) * 60, "E" if lon > 0 else "W")
|
||||||
|
|
||||||
|
config += """
|
||||||
|
PBEACON sendto=IG delay=0:30 every=60:00 symbol="igate" overlay=R lat={lat} long={lon} comment="OpenWebRX APRS gateway"
|
||||||
|
""".format(
|
||||||
|
lat=lat, lon=lon
|
||||||
|
)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class KissClient(object):
|
||||||
|
@staticmethod
|
||||||
|
def getFreePort():
|
||||||
|
# direwolf has some strange hardcoded port ranges
|
||||||
|
while True:
|
||||||
|
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()
|
||||||
|
return port
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __init__(self, port):
|
||||||
|
time.sleep(1)
|
||||||
|
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
self.socket.connect(("localhost", port))
|
||||||
|
|
||||||
|
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
|
||||||
24
owrx/locator.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
class Locator(object):
|
||||||
|
@staticmethod
|
||||||
|
def fromCoordinates(coordinates, depth=3):
|
||||||
|
|
||||||
|
lat, lon = coordinates
|
||||||
|
|
||||||
|
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
|
||||||
116
owrx/map.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
import threading, time
|
||||||
|
from owrx.config import PropertyManager
|
||||||
|
from owrx.bands import Band
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Location(object):
|
||||||
|
def __dict__(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class Map(object):
|
||||||
|
sharedInstance = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getSharedInstance():
|
||||||
|
if Map.sharedInstance is None:
|
||||||
|
Map.sharedInstance = Map()
|
||||||
|
return Map.sharedInstance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.clients = []
|
||||||
|
self.positions = {}
|
||||||
|
|
||||||
|
def removeLoop():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self.removeOldPositions()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("error while removing old map positions")
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
threading.Thread(target=removeLoop, daemon=True).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()
|
||||||
|
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()
|
||||||
|
if callsign in self.positions:
|
||||||
|
self.positions[callsign]["updated"] = ts
|
||||||
|
self.broadcast([{"callsign": callsign, "lastseen": ts.timestamp() * 1000}])
|
||||||
|
|
||||||
|
def removeLocation(self, callsign):
|
||||||
|
self.positions.pop(callsign, None)
|
||||||
|
# TODO broadcast removal to clients
|
||||||
|
|
||||||
|
def removeOldPositions(self):
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
108
owrx/meta.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from owrx.config import PropertyManager
|
||||||
|
from urllib import request
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from owrx.map import Map, LatLngLocation
|
||||||
|
from owrx.bands import Bandplan
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DmrCache(object):
|
||||||
|
sharedInstance = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getSharedInstance():
|
||||||
|
if DmrCache.sharedInstance is None:
|
||||||
|
DmrCache.sharedInstance = DmrCache()
|
||||||
|
return DmrCache.sharedInstance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.cache = {}
|
||||||
|
self.cacheTimeout = timedelta(seconds=86400)
|
||||||
|
|
||||||
|
def isValid(self, key):
|
||||||
|
if not key in self.cache:
|
||||||
|
return False
|
||||||
|
entry = self.cache[key]
|
||||||
|
return entry["timestamp"] + self.cacheTimeout > datetime.now()
|
||||||
|
|
||||||
|
def put(self, key, value):
|
||||||
|
self.cache[key] = {"timestamp": datetime.now(), "data": value}
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
if not self.isValid(key):
|
||||||
|
return None
|
||||||
|
return self.cache[key]["data"]
|
||||||
|
|
||||||
|
|
||||||
|
class DmrMetaEnricher(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.threads = {}
|
||||||
|
|
||||||
|
def downloadRadioIdData(self, id):
|
||||||
|
cache = DmrCache.getSharedInstance()
|
||||||
|
try:
|
||||||
|
logger.debug("requesting DMR metadata for id=%s", id)
|
||||||
|
res = request.urlopen("https://www.radioid.net/api/dmr/user/?id={0}".format(id), timeout=30).read()
|
||||||
|
data = json.loads(res.decode("utf-8"))
|
||||||
|
cache.put(id, data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
cache.put(id, None)
|
||||||
|
del self.threads[id]
|
||||||
|
|
||||||
|
def enrich(self, meta):
|
||||||
|
if not PropertyManager.getSharedInstance()["digital_voice_dmr_id_lookup"]:
|
||||||
|
return None
|
||||||
|
if not "source" in meta:
|
||||||
|
return None
|
||||||
|
id = meta["source"]
|
||||||
|
cache = DmrCache.getSharedInstance()
|
||||||
|
if not cache.isValid(id):
|
||||||
|
if not id in self.threads:
|
||||||
|
self.threads[id] = threading.Thread(target=self.downloadRadioIdData, args=[id])
|
||||||
|
self.threads[id].start()
|
||||||
|
return None
|
||||||
|
data = cache.get(id)
|
||||||
|
if "count" in data and data["count"] > 0 and "results" in data:
|
||||||
|
return data["results"][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class YsfMetaEnricher(object):
|
||||||
|
def __init__(self, parser):
|
||||||
|
self.parser = parser
|
||||||
|
|
||||||
|
def enrich(self, meta):
|
||||||
|
if "source" in meta and "lat" in meta and "lon" in meta:
|
||||||
|
# TODO parsing the float values should probably happen earlier
|
||||||
|
loc = LatLngLocation(float(meta["lat"]), float(meta["lon"]))
|
||||||
|
Map.getSharedInstance().updateLocation(meta["source"], loc, "YSF", self.parser.getBand())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class MetaParser(object):
|
||||||
|
def __init__(self, handler):
|
||||||
|
self.handler = handler
|
||||||
|
self.enrichers = {"DMR": DmrMetaEnricher(), "YSF": YsfMetaEnricher(self)}
|
||||||
|
self.band = None
|
||||||
|
|
||||||
|
def setDialFrequency(self, freq):
|
||||||
|
self.band = Bandplan.getSharedInstance().findBand(freq)
|
||||||
|
|
||||||
|
def getBand(self):
|
||||||
|
return self.band
|
||||||
|
|
||||||
|
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:
|
||||||
|
additional_data = self.enrichers[protocol].enrich(meta)
|
||||||
|
if additional_data is not None:
|
||||||
|
meta["additional"] = additional_data
|
||||||
|
self.handler.write_metadata(meta)
|
||||||
65
owrx/metrics.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
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 = {}
|
||||||
|
|
||||||
|
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 getMetrics(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
|
||||||
193
owrx/pskreporter.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
from sched import scheduler
|
||||||
|
from owrx.config import PropertyManager
|
||||||
|
from owrx.version import openwebrx_version
|
||||||
|
from owrx.locator import Locator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PskReporterDummy(object):
|
||||||
|
"""
|
||||||
|
used in place of the PskReporter when reporting is disabled.
|
||||||
|
does nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def spot(self, spot):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PskReporter(object):
|
||||||
|
sharedInstance = None
|
||||||
|
creationLock = threading.Lock()
|
||||||
|
interval = 300
|
||||||
|
supportedModes = ["FT8", "FT4", "JT9", "JT65"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getSharedInstance():
|
||||||
|
with PskReporter.creationLock:
|
||||||
|
if PskReporter.sharedInstance is None:
|
||||||
|
if PropertyManager.getSharedInstance()["pskreporter_enabled"]:
|
||||||
|
PskReporter.sharedInstance = PskReporter()
|
||||||
|
else:
|
||||||
|
PskReporter.sharedInstance = PskReporterDummy()
|
||||||
|
return PskReporter.sharedInstance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.spots = []
|
||||||
|
self.spotLock = threading.Lock()
|
||||||
|
self.uploader = Uploader()
|
||||||
|
self.scheduler = scheduler(time.time, time.sleep)
|
||||||
|
self.scheduleNextUpload()
|
||||||
|
threading.Thread(target=self.scheduler.run).start()
|
||||||
|
|
||||||
|
def scheduleNextUpload(self):
|
||||||
|
delay = PskReporter.interval + random.uniform(0, 30)
|
||||||
|
logger.debug("scheduling next pskreporter upload in %f seconds", delay)
|
||||||
|
self.scheduler.enter(delay, 1, self.upload)
|
||||||
|
|
||||||
|
def spot(self, spot):
|
||||||
|
if not spot["mode"] in PskReporter.supportedModes:
|
||||||
|
return
|
||||||
|
with self.spotLock:
|
||||||
|
self.spots.append(spot)
|
||||||
|
|
||||||
|
def upload(self):
|
||||||
|
try:
|
||||||
|
with self.spotLock:
|
||||||
|
spots = self.spots
|
||||||
|
self.spots = []
|
||||||
|
|
||||||
|
if spots:
|
||||||
|
self.uploader.upload(spots)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to upload spots")
|
||||||
|
|
||||||
|
self.scheduleNextUpload()
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
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):
|
||||||
|
return bytes(
|
||||||
|
self.encodeString(spot["callsign"])
|
||||||
|
+ list(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"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def getReceiverInformationHeader(self):
|
||||||
|
return bytes(
|
||||||
|
# id, length
|
||||||
|
[0x00, 0x03, 0x00, 0x24]
|
||||||
|
+ Uploader.receieverDelimiter
|
||||||
|
# number of fields
|
||||||
|
+ [0x00, 0x03, 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]
|
||||||
|
# padding
|
||||||
|
+ [0x00, 0x00]
|
||||||
|
)
|
||||||
|
|
||||||
|
def getReceiverInformation(self):
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
callsign = pm["pskreporter_callsign"]
|
||||||
|
locator = Locator.fromCoordinates(pm["receiver_gps"])
|
||||||
|
decodingSoftware = "OpenWebRX " + openwebrx_version
|
||||||
|
body = [b for s in [callsign, locator, decodingSoftware] 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)])
|
||||||
39
owrx/sdrhu.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from owrx.config import PropertyManager
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SdrHuUpdater(threading.Thread):
|
||||||
|
def __init__(self):
|
||||||
|
self.doRun = True
|
||||||
|
super().__init__(daemon=True)
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
cmd = 'wget --timeout=15 -4qO- https://sdr.hu/update --post-data "url=http://{server_hostname}:{web_port}&apikey={sdrhu_key}" 2>&1'.format(
|
||||||
|
**pm.__dict__()
|
||||||
|
)
|
||||||
|
logger.debug(cmd)
|
||||||
|
returned = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate()
|
||||||
|
returned = returned[0].decode("utf-8")
|
||||||
|
if "UPDATE:" in returned:
|
||||||
|
retrytime_mins = 20
|
||||||
|
value = returned.split("UPDATE:")[1].split("\n", 1)[0]
|
||||||
|
if value.startswith("SUCCESS"):
|
||||||
|
logger.info("Update succeeded!")
|
||||||
|
else:
|
||||||
|
logger.warning("Update failed, your receiver cannot be listed on sdr.hu! Reason: %s", value)
|
||||||
|
else:
|
||||||
|
retrytime_mins = 2
|
||||||
|
logger.warning("wget failed while updating, your receiver cannot be listed on sdr.hu!")
|
||||||
|
return retrytime_mins
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while self.doRun:
|
||||||
|
retrytime_mins = self.update()
|
||||||
|
time.sleep(60 * retrytime_mins)
|
||||||
349
owrx/service.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import threading
|
||||||
|
import socket
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from owrx.source import SdrService
|
||||||
|
from owrx.bands import Bandplan
|
||||||
|
from csdr import dsp, output
|
||||||
|
from owrx.wsjt import WsjtParser
|
||||||
|
from owrx.aprs import AprsParser
|
||||||
|
from owrx.config import PropertyManager
|
||||||
|
from owrx.source import Resampler
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceOutput(output):
|
||||||
|
def __init__(self, frequency):
|
||||||
|
self.frequency = frequency
|
||||||
|
|
||||||
|
def getParser(self):
|
||||||
|
# abstract method; implement in subclasses
|
||||||
|
pass
|
||||||
|
|
||||||
|
def receive_output(self, t, read_fn):
|
||||||
|
parser = self.getParser()
|
||||||
|
parser.setDialFrequency(self.frequency)
|
||||||
|
target = self.pump(read_fn, parser.parse)
|
||||||
|
threading.Thread(target=target).start()
|
||||||
|
|
||||||
|
|
||||||
|
class WsjtServiceOutput(ServiceOutput):
|
||||||
|
def getParser(self):
|
||||||
|
return WsjtParser(WsjtHandler())
|
||||||
|
|
||||||
|
def supports_type(self, t):
|
||||||
|
return t == "wsjt_demod"
|
||||||
|
|
||||||
|
|
||||||
|
class AprsServiceOutput(ServiceOutput):
|
||||||
|
def getParser(self):
|
||||||
|
return AprsParser(AprsHandler())
|
||||||
|
|
||||||
|
def supports_type(self, t):
|
||||||
|
return t == "packet_demod"
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleEntry(object):
|
||||||
|
def __init__(self, startTime, endTime, profile):
|
||||||
|
self.startTime = startTime
|
||||||
|
self.endTime = endTime
|
||||||
|
self.profile = profile
|
||||||
|
|
||||||
|
def isCurrent(self, time):
|
||||||
|
if self.startTime < self.endTime:
|
||||||
|
return self.startTime <= time < self.endTime
|
||||||
|
else:
|
||||||
|
return self.startTime <= time or time < self.endTime
|
||||||
|
|
||||||
|
def getProfile(self):
|
||||||
|
return self.profile
|
||||||
|
|
||||||
|
def getScheduledEnd(self):
|
||||||
|
now = datetime.utcnow()
|
||||||
|
end = now.combine(date=now.date(), time=self.endTime)
|
||||||
|
while end < now:
|
||||||
|
end += timedelta(days=1)
|
||||||
|
return end
|
||||||
|
|
||||||
|
def getNextActivation(self):
|
||||||
|
now = datetime.utcnow()
|
||||||
|
start = now.combine(date=now.date(), time=self.startTime)
|
||||||
|
while start < now:
|
||||||
|
start += timedelta(days=1)
|
||||||
|
return start
|
||||||
|
|
||||||
|
|
||||||
|
class Schedule(object):
|
||||||
|
@staticmethod
|
||||||
|
def parse(scheduleDict):
|
||||||
|
entries = []
|
||||||
|
for time, profile in scheduleDict.items():
|
||||||
|
if len(time) != 9:
|
||||||
|
logger.warning("invalid schedule spec: %s", time)
|
||||||
|
continue
|
||||||
|
|
||||||
|
startTime = datetime.strptime(time[0:4], "%H%M").replace(tzinfo=timezone.utc).time()
|
||||||
|
endTime = datetime.strptime(time[5:9], "%H%M").replace(tzinfo=timezone.utc).time()
|
||||||
|
entries.append(ScheduleEntry(startTime, endTime, profile))
|
||||||
|
return Schedule(entries)
|
||||||
|
|
||||||
|
def __init__(self, entries):
|
||||||
|
self.entries = entries
|
||||||
|
|
||||||
|
def getCurrentEntry(self):
|
||||||
|
current = [p for p in self.entries if p.isCurrent(datetime.utcnow().time())]
|
||||||
|
if current:
|
||||||
|
return current[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getNextEntry(self):
|
||||||
|
s = sorted(self.entries, key=lambda e: e.getNextActivation())
|
||||||
|
if s:
|
||||||
|
return s[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceScheduler(object):
|
||||||
|
def __init__(self, source, schedule):
|
||||||
|
self.source = source
|
||||||
|
self.schedule = Schedule.parse(schedule)
|
||||||
|
self.active = False
|
||||||
|
self.source.addClient(self)
|
||||||
|
self.selectionTimer = None
|
||||||
|
self.scheduleSelection()
|
||||||
|
|
||||||
|
def scheduleSelection(self, time=None):
|
||||||
|
seconds = 10
|
||||||
|
if time is not None:
|
||||||
|
delta = time - datetime.utcnow()
|
||||||
|
seconds = delta.total_seconds()
|
||||||
|
if self.selectionTimer:
|
||||||
|
self.selectionTimer.cancel()
|
||||||
|
self.selectionTimer = threading.Timer(seconds, self.selectProfile)
|
||||||
|
self.selectionTimer.start()
|
||||||
|
|
||||||
|
def isActive(self):
|
||||||
|
return self.active
|
||||||
|
|
||||||
|
def onSdrAvailable(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def onSdrUnavailable(self):
|
||||||
|
self.scheduleSelection()
|
||||||
|
|
||||||
|
def selectProfile(self):
|
||||||
|
self.active = False
|
||||||
|
if self.source.hasActiveClients():
|
||||||
|
logger.debug("source has active clients; not touching")
|
||||||
|
return
|
||||||
|
logger.debug("source seems to be idle, selecting profile for background services")
|
||||||
|
entry = self.schedule.getCurrentEntry()
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
logger.debug("schedule did not return a profile. checking next entry...")
|
||||||
|
nextEntry = self.schedule.getNextEntry()
|
||||||
|
if nextEntry is not None:
|
||||||
|
self.scheduleSelection(nextEntry.getNextActivation())
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug("scheduling end for current profile: %s", entry.getScheduledEnd())
|
||||||
|
self.scheduleSelection(entry.getScheduledEnd())
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.active = True
|
||||||
|
self.source.activateProfile(entry.getProfile())
|
||||||
|
self.source.start()
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceHandler(object):
|
||||||
|
def __init__(self, source):
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.services = []
|
||||||
|
self.source = source
|
||||||
|
self.startupTimer = None
|
||||||
|
self.source.addClient(self)
|
||||||
|
props = self.source.getProps()
|
||||||
|
props.collect("center_freq", "samp_rate").wire(self.onFrequencyChange)
|
||||||
|
if self.source.isAvailable():
|
||||||
|
self.scheduleServiceStartup()
|
||||||
|
if "schedule" in props:
|
||||||
|
ServiceScheduler(self.source, props["schedule"])
|
||||||
|
|
||||||
|
def isActive(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def onSdrAvailable(self):
|
||||||
|
self.scheduleServiceStartup()
|
||||||
|
|
||||||
|
def onSdrUnavailable(self):
|
||||||
|
logger.debug("sdr source becoming unavailable; stopping services.")
|
||||||
|
self.stopServices()
|
||||||
|
|
||||||
|
def isSupported(self, mode):
|
||||||
|
return mode in PropertyManager.getSharedInstance()["services_decoders"]
|
||||||
|
|
||||||
|
def stopServices(self):
|
||||||
|
with self.lock:
|
||||||
|
services = self.services
|
||||||
|
self.services = []
|
||||||
|
|
||||||
|
for service in services:
|
||||||
|
service.stop()
|
||||||
|
|
||||||
|
def onFrequencyChange(self, key, value):
|
||||||
|
self.stopServices()
|
||||||
|
if not self.source.isAvailable():
|
||||||
|
return
|
||||||
|
self.scheduleServiceStartup()
|
||||||
|
|
||||||
|
def scheduleServiceStartup(self):
|
||||||
|
if self.startupTimer:
|
||||||
|
self.startupTimer.cancel()
|
||||||
|
self.startupTimer = threading.Timer(10, self.updateServices)
|
||||||
|
self.startupTimer.start()
|
||||||
|
|
||||||
|
def getAvailablePort(self):
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.bind(("", 0))
|
||||||
|
s.listen(1)
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
def updateServices(self):
|
||||||
|
logger.debug("re-scheduling services due to sdr changes")
|
||||||
|
self.stopServices()
|
||||||
|
if not self.source.isAvailable():
|
||||||
|
logger.debug("sdr source is unavailable")
|
||||||
|
return
|
||||||
|
cf = self.source.getProps()["center_freq"]
|
||||||
|
sr = self.source.getProps()["samp_rate"]
|
||||||
|
srh = sr / 2
|
||||||
|
frequency_range = (cf - srh, cf + srh)
|
||||||
|
|
||||||
|
dials = [
|
||||||
|
dial
|
||||||
|
for dial in Bandplan.getSharedInstance().collectDialFrequencies(frequency_range)
|
||||||
|
if self.isSupported(dial["mode"])
|
||||||
|
]
|
||||||
|
|
||||||
|
if not dials:
|
||||||
|
logger.debug("no services available")
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
self.services = []
|
||||||
|
|
||||||
|
for group in self.optimizeResampling(dials, sr):
|
||||||
|
frequencies = sorted([f["frequency"] for f in group])
|
||||||
|
min = frequencies[0]
|
||||||
|
max = frequencies[-1]
|
||||||
|
cf = (min + max) / 2
|
||||||
|
bw = max - min
|
||||||
|
logger.debug("group center frequency: {0}, bandwidth: {1}".format(cf, bw))
|
||||||
|
resampler_props = PropertyManager()
|
||||||
|
resampler_props["center_freq"] = cf
|
||||||
|
# TODO the + 24000 is a temporary fix since the resampling optimizer does not account for required bandwidths
|
||||||
|
resampler_props["samp_rate"] = bw + 24000
|
||||||
|
resampler = Resampler(resampler_props, self.getAvailablePort(), self.source)
|
||||||
|
resampler.start()
|
||||||
|
self.services.append(resampler)
|
||||||
|
|
||||||
|
for dial in group:
|
||||||
|
self.services.append(self.setupService(dial["mode"], dial["frequency"], resampler))
|
||||||
|
|
||||||
|
def optimizeResampling(self, freqs, bandwidth):
|
||||||
|
freqs = sorted(freqs, key=lambda f: f["frequency"])
|
||||||
|
distances = [
|
||||||
|
{"frequency": freqs[i]["frequency"], "distance": freqs[i + 1]["frequency"] - freqs[i]["frequency"]}
|
||||||
|
for i in range(0, len(freqs) - 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
distances = [d for d in distances if d["distance"] > 0]
|
||||||
|
|
||||||
|
distances = sorted(distances, key=lambda f: f["distance"], reverse=True)
|
||||||
|
|
||||||
|
def calculate_usage(num_splits):
|
||||||
|
splits = sorted([f["frequency"] for f in distances[0:num_splits]])
|
||||||
|
previous = 0
|
||||||
|
groups = []
|
||||||
|
for split in splits:
|
||||||
|
groups.append([f for f in freqs if previous < f["frequency"] <= split])
|
||||||
|
previous = split
|
||||||
|
groups.append([f for f in freqs if previous < f["frequency"]])
|
||||||
|
|
||||||
|
def get_bandwitdh(group):
|
||||||
|
freqs = sorted([f["frequency"] for f in group])
|
||||||
|
# the group will process the full BW once, plus the reduced BW once for each group member
|
||||||
|
return bandwidth + len(group) * (freqs[-1] - freqs[0] + 24000)
|
||||||
|
|
||||||
|
total_bandwidth = sum([get_bandwitdh(group) for group in groups])
|
||||||
|
return {"num_splits": num_splits, "total_bandwidth": total_bandwidth, "groups": groups}
|
||||||
|
|
||||||
|
usages = [calculate_usage(i) for i in range(0, len(freqs))]
|
||||||
|
# this is simulating no resampling. i haven't seen this as the best result yet
|
||||||
|
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"]))
|
||||||
|
|
||||||
|
return results[0]["groups"]
|
||||||
|
|
||||||
|
def setupService(self, mode, frequency, source):
|
||||||
|
logger.debug("setting up service {0} on frequency {1}".format(mode, frequency))
|
||||||
|
# TODO selecting outputs will need some more intelligence here
|
||||||
|
if mode == "packet":
|
||||||
|
output = AprsServiceOutput(frequency)
|
||||||
|
else:
|
||||||
|
output = WsjtServiceOutput(frequency)
|
||||||
|
d = dsp(output)
|
||||||
|
d.nc_port = source.getPort()
|
||||||
|
d.set_offset_freq(frequency - source.getProps()["center_freq"])
|
||||||
|
if mode == "packet":
|
||||||
|
d.set_demodulator("nfm")
|
||||||
|
d.set_bpf(-4000, 4000)
|
||||||
|
elif mode == "wspr":
|
||||||
|
d.set_demodulator("usb")
|
||||||
|
# WSPR only samples between 1400 and 1600 Hz
|
||||||
|
d.set_bpf(1350, 1650)
|
||||||
|
else:
|
||||||
|
d.set_demodulator("usb")
|
||||||
|
d.set_bpf(0, 3000)
|
||||||
|
d.set_secondary_demodulator(mode)
|
||||||
|
d.set_audio_compression("none")
|
||||||
|
d.set_samp_rate(source.getProps()["samp_rate"])
|
||||||
|
d.set_service()
|
||||||
|
d.start()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class WsjtHandler(object):
|
||||||
|
def write_wsjt_message(self, msg):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AprsHandler(object):
|
||||||
|
def write_aprs_data(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Services(object):
|
||||||
|
@staticmethod
|
||||||
|
def start():
|
||||||
|
if not PropertyManager.getSharedInstance()["services_enabled"]:
|
||||||
|
return
|
||||||
|
for source in SdrService.getSources().values():
|
||||||
|
ServiceHandler(source)
|
||||||
|
|
||||||
|
|
||||||
|
class Service(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WsjtService(Service):
|
||||||
|
pass
|
||||||
739
owrx/source.py
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
import subprocess
|
||||||
|
from owrx.config import PropertyManager
|
||||||
|
from owrx.feature import FeatureDetector, UnknownFeatureException
|
||||||
|
from owrx.meta import MetaParser
|
||||||
|
from owrx.wsjt import WsjtParser
|
||||||
|
from owrx.aprs import AprsParser
|
||||||
|
from owrx.metrics import Metrics, DirectMetric
|
||||||
|
import threading
|
||||||
|
import csdr
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import socket
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SdrService(object):
|
||||||
|
sdrProps = None
|
||||||
|
sources = {}
|
||||||
|
lastPort = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getNextPort():
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
(start, end) = pm["iq_port_range"]
|
||||||
|
if SdrService.lastPort is None:
|
||||||
|
SdrService.lastPort = start
|
||||||
|
else:
|
||||||
|
SdrService.lastPort += 1
|
||||||
|
if SdrService.lastPort > end:
|
||||||
|
raise IndexError("no more available ports to start more sdrs")
|
||||||
|
return SdrService.lastPort
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def loadProps():
|
||||||
|
if SdrService.sdrProps is None:
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
featureDetector = FeatureDetector()
|
||||||
|
|
||||||
|
def loadIntoPropertyManager(dict: dict):
|
||||||
|
propertyManager = PropertyManager()
|
||||||
|
for (name, value) in dict.items():
|
||||||
|
propertyManager[name] = value
|
||||||
|
return propertyManager
|
||||||
|
|
||||||
|
def sdrTypeAvailable(value):
|
||||||
|
try:
|
||||||
|
if not featureDetector.is_available(value["type"]):
|
||||||
|
logger.error(
|
||||||
|
'The RTL source type "{0}" is not available. please check requirements.'.format(
|
||||||
|
value["type"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except UnknownFeatureException:
|
||||||
|
logger.error(
|
||||||
|
'The RTL source type "{0}" is invalid. Please check your configuration'.format(value["type"])
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# transform all dictionary items into PropertyManager object, filtering out unavailable ones
|
||||||
|
SdrService.sdrProps = {
|
||||||
|
name: loadIntoPropertyManager(value) for (name, value) in pm["sdrs"].items() if sdrTypeAvailable(value)
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"SDR sources loaded. Availables SDRs: {0}".format(
|
||||||
|
", ".join(map(lambda x: x["name"], SdrService.sdrProps.values()))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getSource(id=None):
|
||||||
|
SdrService.loadProps()
|
||||||
|
if id is None:
|
||||||
|
# TODO: configure default sdr in config? right now it will pick the first one off the list.
|
||||||
|
id = list(SdrService.sdrProps.keys())[0]
|
||||||
|
sources = SdrService.getSources()
|
||||||
|
return sources[id]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getSources():
|
||||||
|
SdrService.loadProps()
|
||||||
|
for id in SdrService.sdrProps.keys():
|
||||||
|
if not id in SdrService.sources:
|
||||||
|
props = SdrService.sdrProps[id]
|
||||||
|
className = "".join(x for x in props["type"].title() if x.isalnum()) + "Source"
|
||||||
|
cls = getattr(sys.modules[__name__], className)
|
||||||
|
SdrService.sources[id] = cls(props, SdrService.getNextPort())
|
||||||
|
return SdrService.sources
|
||||||
|
|
||||||
|
|
||||||
|
class SdrSourceException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SdrSource(object):
|
||||||
|
def __init__(self, props, port):
|
||||||
|
self.props = props
|
||||||
|
self.activateProfile()
|
||||||
|
self.rtlProps = self.props.collect(
|
||||||
|
"samp_rate", "nmux_memory", "center_freq", "ppm", "rf_gain", "lna_gain", "rf_amp", "antenna", "if_gain"
|
||||||
|
).defaults(PropertyManager.getSharedInstance())
|
||||||
|
|
||||||
|
def restart(name, value):
|
||||||
|
logger.debug("restarting sdr source due to property change: {0} changed to {1}".format(name, value))
|
||||||
|
self.stop()
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
self.rtlProps.wire(restart)
|
||||||
|
self.port = port
|
||||||
|
self.monitor = None
|
||||||
|
self.clients = []
|
||||||
|
self.spectrumClients = []
|
||||||
|
self.spectrumThread = None
|
||||||
|
self.process = None
|
||||||
|
self.modificationLock = threading.Lock()
|
||||||
|
|
||||||
|
# override this in subclasses
|
||||||
|
def getCommand(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# override this in subclasses, if necessary
|
||||||
|
def getFormatConversion(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def activateProfile(self, profile_id=None):
|
||||||
|
profiles = self.props["profiles"]
|
||||||
|
if profile_id is None:
|
||||||
|
profile_id = list(profiles.keys())[0]
|
||||||
|
logger.debug("activating profile {0}".format(profile_id))
|
||||||
|
profile = profiles[profile_id]
|
||||||
|
for (key, value) in profile.items():
|
||||||
|
# skip the name, that would overwrite the source name.
|
||||||
|
if key == "name":
|
||||||
|
continue
|
||||||
|
self.props[key] = value
|
||||||
|
|
||||||
|
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 start(self):
|
||||||
|
self.modificationLock.acquire()
|
||||||
|
if self.monitor:
|
||||||
|
self.modificationLock.release()
|
||||||
|
return
|
||||||
|
|
||||||
|
props = self.rtlProps
|
||||||
|
|
||||||
|
start_sdr_command = self.getCommand().format(
|
||||||
|
**props.collect(
|
||||||
|
"samp_rate", "center_freq", "ppm", "rf_gain", "lna_gain", "rf_amp", "antenna", "if_gain"
|
||||||
|
).__dict__()
|
||||||
|
)
|
||||||
|
|
||||||
|
format_conversion = self.getFormatConversion()
|
||||||
|
if format_conversion is not None:
|
||||||
|
start_sdr_command += " | " + format_conversion
|
||||||
|
|
||||||
|
nmux_bufcnt = nmux_bufsize = 0
|
||||||
|
while nmux_bufsize < props["samp_rate"] / 4:
|
||||||
|
nmux_bufsize += 4096
|
||||||
|
while nmux_bufsize * nmux_bufcnt < props["nmux_memory"] * 1e6:
|
||||||
|
nmux_bufcnt += 1
|
||||||
|
if nmux_bufcnt == 0 or nmux_bufsize == 0:
|
||||||
|
logger.error(
|
||||||
|
"Error: nmux_bufsize or nmux_bufcnt is zero. These depend on nmux_memory and samp_rate options in config_webrx.py"
|
||||||
|
)
|
||||||
|
self.modificationLock.release()
|
||||||
|
return
|
||||||
|
logger.debug("nmux_bufsize = %d, nmux_bufcnt = %d" % (nmux_bufsize, nmux_bufcnt))
|
||||||
|
cmd = start_sdr_command + " | nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" % (
|
||||||
|
nmux_bufsize,
|
||||||
|
nmux_bufcnt,
|
||||||
|
self.port,
|
||||||
|
)
|
||||||
|
self.process = subprocess.Popen(cmd, shell=True, preexec_fn=os.setpgrp)
|
||||||
|
logger.info("Started rtl source: " + cmd)
|
||||||
|
|
||||||
|
available = False
|
||||||
|
|
||||||
|
def wait_for_process_to_end():
|
||||||
|
rc = self.process.wait()
|
||||||
|
logger.debug("shut down with RC={0}".format(rc))
|
||||||
|
self.monitor = None
|
||||||
|
|
||||||
|
self.monitor = threading.Thread(target=wait_for_process_to_end)
|
||||||
|
self.monitor.start()
|
||||||
|
|
||||||
|
retries = 1000
|
||||||
|
while retries > 0:
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.modificationLock.release()
|
||||||
|
|
||||||
|
if not available:
|
||||||
|
raise SdrSourceException("rtl source failed to start up")
|
||||||
|
|
||||||
|
for c in self.clients:
|
||||||
|
c.onSdrAvailable()
|
||||||
|
|
||||||
|
def isAvailable(self):
|
||||||
|
return self.monitor is not None
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
for c in self.clients:
|
||||||
|
c.onSdrUnavailable()
|
||||||
|
|
||||||
|
self.modificationLock.acquire()
|
||||||
|
|
||||||
|
if self.process is not None:
|
||||||
|
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()
|
||||||
|
self.sleepOnRestart()
|
||||||
|
self.modificationLock.release()
|
||||||
|
|
||||||
|
def sleepOnRestart(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def hasActiveClients(self):
|
||||||
|
activeClients = [c for c in self.clients if c.isActive()]
|
||||||
|
return len(activeClients) > 0
|
||||||
|
|
||||||
|
def addClient(self, c):
|
||||||
|
self.clients.append(c)
|
||||||
|
if self.hasActiveClients():
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def removeClient(self, c):
|
||||||
|
try:
|
||||||
|
self.clients.remove(c)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if not self.hasActiveClients():
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def addSpectrumClient(self, c):
|
||||||
|
self.spectrumClients.append(c)
|
||||||
|
if self.spectrumThread is None:
|
||||||
|
self.spectrumThread = SpectrumThread(self)
|
||||||
|
self.spectrumThread.start()
|
||||||
|
|
||||||
|
def removeSpectrumClient(self, c):
|
||||||
|
try:
|
||||||
|
self.spectrumClients.remove(c)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class Resampler(SdrSource):
|
||||||
|
def __init__(self, props, port, 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__(props, port)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.modificationLock.acquire()
|
||||||
|
if self.monitor:
|
||||||
|
self.modificationLock.release()
|
||||||
|
return
|
||||||
|
|
||||||
|
props = self.rtlProps
|
||||||
|
|
||||||
|
resampler_command = [
|
||||||
|
"nc -v 127.0.0.1 {nc_port}".format(nc_port=self.sdr.getPort()),
|
||||||
|
"csdr shift_addition_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
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
nmux_bufcnt = nmux_bufsize = 0
|
||||||
|
while nmux_bufsize < props["samp_rate"] / 4:
|
||||||
|
nmux_bufsize += 4096
|
||||||
|
while nmux_bufsize * nmux_bufcnt < props["nmux_memory"] * 1e6:
|
||||||
|
nmux_bufcnt += 1
|
||||||
|
if nmux_bufcnt == 0 or nmux_bufsize == 0:
|
||||||
|
logger.error(
|
||||||
|
"Error: nmux_bufsize or nmux_bufcnt is zero. These depend on nmux_memory and samp_rate options in config_webrx.py"
|
||||||
|
)
|
||||||
|
self.modificationLock.release()
|
||||||
|
return
|
||||||
|
logger.debug("nmux_bufsize = %d, nmux_bufcnt = %d" % (nmux_bufsize, nmux_bufcnt))
|
||||||
|
resampler_command += [
|
||||||
|
"nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" % (nmux_bufsize, nmux_bufcnt, self.port)
|
||||||
|
]
|
||||||
|
cmd = " | ".join(resampler_command)
|
||||||
|
logger.debug("resampler command: %s", cmd)
|
||||||
|
self.process = subprocess.Popen(cmd, shell=True, preexec_fn=os.setpgrp)
|
||||||
|
logger.info("Started resampler source: " + cmd)
|
||||||
|
|
||||||
|
available = False
|
||||||
|
|
||||||
|
def wait_for_process_to_end():
|
||||||
|
rc = self.process.wait()
|
||||||
|
logger.debug("shut down with RC={0}".format(rc))
|
||||||
|
self.monitor = None
|
||||||
|
|
||||||
|
self.monitor = threading.Thread(target=wait_for_process_to_end)
|
||||||
|
self.monitor.start()
|
||||||
|
|
||||||
|
retries = 1000
|
||||||
|
while retries > 0:
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.modificationLock.release()
|
||||||
|
|
||||||
|
if not available:
|
||||||
|
raise SdrSourceException("resampler source failed to start up")
|
||||||
|
|
||||||
|
for c in self.clients:
|
||||||
|
c.onSdrAvailable()
|
||||||
|
|
||||||
|
def activateProfile(self, profile_id=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RtlSdrSource(SdrSource):
|
||||||
|
def getCommand(self):
|
||||||
|
return "rtl_sdr -s {samp_rate} -f {center_freq} -p {ppm} -g {rf_gain} -"
|
||||||
|
|
||||||
|
def getFormatConversion(self):
|
||||||
|
return "csdr convert_u8_f"
|
||||||
|
|
||||||
|
|
||||||
|
class HackrfSource(SdrSource):
|
||||||
|
def getCommand(self):
|
||||||
|
return "hackrf_transfer -s {samp_rate} -f {center_freq} -g {rf_gain} -l{lna_gain} -a{rf_amp} -r-"
|
||||||
|
|
||||||
|
def getFormatConversion(self):
|
||||||
|
return "csdr convert_s8_f"
|
||||||
|
|
||||||
|
|
||||||
|
class SdrplaySource(SdrSource):
|
||||||
|
def getCommand(self):
|
||||||
|
command = "rx_sdr -F CF32 -s {samp_rate} -f {center_freq} -p {ppm}"
|
||||||
|
gainMap = {"rf_gain": "RFGR", "if_gain": "IFGR"}
|
||||||
|
gains = [
|
||||||
|
"{0}={{{1}}}".format(gainMap[name], name)
|
||||||
|
for (name, value) in self.rtlProps.collect("rf_gain", "if_gain").__dict__().items()
|
||||||
|
if value is not None
|
||||||
|
]
|
||||||
|
if gains:
|
||||||
|
command += " -g {gains}".format(gains=",".join(gains))
|
||||||
|
if self.rtlProps["antenna"] is not None:
|
||||||
|
command += ' -a "{antenna}"'
|
||||||
|
command += " -"
|
||||||
|
return command
|
||||||
|
|
||||||
|
def sleepOnRestart(self):
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
class AirspySource(SdrSource):
|
||||||
|
def getCommand(self):
|
||||||
|
frequency = self.props["center_freq"] / 1e6
|
||||||
|
command = "airspy_rx"
|
||||||
|
command += " -f{0}".format(frequency)
|
||||||
|
command += " -r /dev/stdout -a{samp_rate} -g {rf_gain}"
|
||||||
|
return command
|
||||||
|
|
||||||
|
def getFormatConversion(self):
|
||||||
|
return "csdr convert_s16_f"
|
||||||
|
|
||||||
|
|
||||||
|
class SpectrumThread(csdr.output):
|
||||||
|
def __init__(self, sdrSource):
|
||||||
|
self.sdrSource = sdrSource
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.props = props = self.sdrSource.props.collect(
|
||||||
|
"samp_rate",
|
||||||
|
"fft_size",
|
||||||
|
"fft_fps",
|
||||||
|
"fft_voverlap_factor",
|
||||||
|
"fft_compression",
|
||||||
|
"csdr_dynamic_bufsize",
|
||||||
|
"csdr_print_bufsizes",
|
||||||
|
"csdr_through",
|
||||||
|
"temporary_directory",
|
||||||
|
).defaults(PropertyManager.getSharedInstance())
|
||||||
|
|
||||||
|
self.dsp = dsp = csdr.dsp(self)
|
||||||
|
dsp.nc_port = self.sdrSource.getPort()
|
||||||
|
dsp.set_demodulator("fft")
|
||||||
|
|
||||||
|
def set_fft_averages(key, value):
|
||||||
|
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.getProperty("samp_rate").wire(dsp.set_samp_rate),
|
||||||
|
props.getProperty("fft_size").wire(dsp.set_fft_size),
|
||||||
|
props.getProperty("fft_fps").wire(dsp.set_fft_fps),
|
||||||
|
props.getProperty("fft_compression").wire(dsp.set_fft_compression),
|
||||||
|
props.getProperty("temporary_directory").wire(dsp.set_temporary_directory),
|
||||||
|
props.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages),
|
||||||
|
]
|
||||||
|
|
||||||
|
set_fft_averages(None, None)
|
||||||
|
|
||||||
|
dsp.csdr_dynamic_bufsize = props["csdr_dynamic_bufsize"]
|
||||||
|
dsp.csdr_print_bufsizes = props["csdr_print_bufsizes"]
|
||||||
|
dsp.csdr_through = props["csdr_through"]
|
||||||
|
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):
|
||||||
|
if self.props["csdr_dynamic_bufsize"]:
|
||||||
|
read_fn(8) # dummy read to skip bufsize & preamble
|
||||||
|
logger.debug("Note: CSDR_DYNAMIC_BUFSIZE_ON = 1")
|
||||||
|
|
||||||
|
threading.Thread(target=self.pump(read_fn, self.sdrSource.writeSpectrumData)).start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.dsp.stop()
|
||||||
|
self.sdrSource.removeClient(self)
|
||||||
|
for c in self.subscriptions:
|
||||||
|
c.cancel()
|
||||||
|
self.subscriptions = []
|
||||||
|
|
||||||
|
def isActive(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def onSdrAvailable(self):
|
||||||
|
self.dsp.start()
|
||||||
|
|
||||||
|
def onSdrUnavailable(self):
|
||||||
|
self.dsp.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class DspManager(csdr.output):
|
||||||
|
def __init__(self, handler, sdrSource):
|
||||||
|
self.handler = handler
|
||||||
|
self.sdrSource = sdrSource
|
||||||
|
self.metaParser = MetaParser(self.handler)
|
||||||
|
self.wsjtParser = WsjtParser(self.handler)
|
||||||
|
self.aprsParser = AprsParser(self.handler)
|
||||||
|
|
||||||
|
self.localProps = (
|
||||||
|
self.sdrSource.getProps()
|
||||||
|
.collect(
|
||||||
|
"audio_compression",
|
||||||
|
"fft_compression",
|
||||||
|
"digimodes_fft_size",
|
||||||
|
"csdr_dynamic_bufsize",
|
||||||
|
"csdr_print_bufsizes",
|
||||||
|
"csdr_through",
|
||||||
|
"digimodes_enable",
|
||||||
|
"samp_rate",
|
||||||
|
"digital_voice_unvoiced_quality",
|
||||||
|
"dmr_filter",
|
||||||
|
"temporary_directory",
|
||||||
|
"center_freq",
|
||||||
|
)
|
||||||
|
.defaults(PropertyManager.getSharedInstance())
|
||||||
|
)
|
||||||
|
|
||||||
|
self.dsp = csdr.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(key, value):
|
||||||
|
freq = self.localProps["center_freq"] + self.localProps["offset_freq"]
|
||||||
|
self.wsjtParser.setDialFrequency(freq)
|
||||||
|
self.aprsParser.setDialFrequency(freq)
|
||||||
|
self.metaParser.setDialFrequency(freq)
|
||||||
|
|
||||||
|
self.subscriptions = [
|
||||||
|
self.localProps.getProperty("audio_compression").wire(self.dsp.set_audio_compression),
|
||||||
|
self.localProps.getProperty("fft_compression").wire(self.dsp.set_fft_compression),
|
||||||
|
self.localProps.getProperty("digimodes_fft_size").wire(self.dsp.set_secondary_fft_size),
|
||||||
|
self.localProps.getProperty("samp_rate").wire(self.dsp.set_samp_rate),
|
||||||
|
self.localProps.getProperty("output_rate").wire(self.dsp.set_output_rate),
|
||||||
|
self.localProps.getProperty("offset_freq").wire(self.dsp.set_offset_freq),
|
||||||
|
self.localProps.getProperty("squelch_level").wire(self.dsp.set_squelch_level),
|
||||||
|
self.localProps.getProperty("low_cut").wire(set_low_cut),
|
||||||
|
self.localProps.getProperty("high_cut").wire(set_high_cut),
|
||||||
|
self.localProps.getProperty("mod").wire(self.dsp.set_demodulator),
|
||||||
|
self.localProps.getProperty("digital_voice_unvoiced_quality").wire(self.dsp.set_unvoiced_quality),
|
||||||
|
self.localProps.getProperty("dmr_filter").wire(self.dsp.set_dmr_filter),
|
||||||
|
self.localProps.getProperty("temporary_directory").wire(self.dsp.set_temporary_directory),
|
||||||
|
self.localProps.collect("center_freq", "offset_freq").wire(set_dial_freq),
|
||||||
|
]
|
||||||
|
|
||||||
|
self.dsp.set_offset_freq(0)
|
||||||
|
self.dsp.set_bpf(-4000, 4000)
|
||||||
|
self.dsp.csdr_dynamic_bufsize = self.localProps["csdr_dynamic_bufsize"]
|
||||||
|
self.dsp.csdr_print_bufsizes = self.localProps["csdr_print_bufsizes"]
|
||||||
|
self.dsp.csdr_through = self.localProps["csdr_through"]
|
||||||
|
|
||||||
|
if self.localProps["digimodes_enable"]:
|
||||||
|
|
||||||
|
def set_secondary_mod(mod):
|
||||||
|
if mod == False:
|
||||||
|
mod = None
|
||||||
|
self.dsp.set_secondary_demodulator(mod)
|
||||||
|
if mod is not None:
|
||||||
|
self.handler.write_secondary_dsp_config(
|
||||||
|
{
|
||||||
|
"secondary_fft_size": self.localProps["digimodes_fft_size"],
|
||||||
|
"if_samp_rate": self.dsp.if_samp_rate(),
|
||||||
|
"secondary_bw": self.dsp.secondary_bw(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.subscriptions += [
|
||||||
|
self.localProps.getProperty("secondary_mod").wire(set_secondary_mod),
|
||||||
|
self.localProps.getProperty("secondary_offset_freq").wire(self.dsp.set_secondary_offset_freq),
|
||||||
|
]
|
||||||
|
|
||||||
|
self.sdrSource.addClient(self)
|
||||||
|
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.sdrSource.isAvailable():
|
||||||
|
self.dsp.start()
|
||||||
|
|
||||||
|
def receive_output(self, t, read_fn):
|
||||||
|
logger.debug("adding new output of type %s", t)
|
||||||
|
writers = {
|
||||||
|
"audio": self.handler.write_dsp_data,
|
||||||
|
"smeter": self.handler.write_s_meter_level,
|
||||||
|
"secondary_fft": self.handler.write_secondary_fft,
|
||||||
|
"secondary_demod": self.handler.write_secondary_demod,
|
||||||
|
"meta": self.metaParser.parse,
|
||||||
|
"wsjt_demod": self.wsjtParser.parse,
|
||||||
|
"packet_demod": self.aprsParser.parse,
|
||||||
|
}
|
||||||
|
write = writers[t]
|
||||||
|
|
||||||
|
threading.Thread(target=self.pump(read_fn, write)).start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.dsp.stop()
|
||||||
|
self.sdrSource.removeClient(self)
|
||||||
|
for sub in self.subscriptions:
|
||||||
|
sub.cancel()
|
||||||
|
self.subscriptions = []
|
||||||
|
|
||||||
|
def setProperty(self, prop, value):
|
||||||
|
self.localProps.getProperty(prop).setValue(value)
|
||||||
|
|
||||||
|
def isActive(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def onSdrAvailable(self):
|
||||||
|
logger.debug("received onSdrAvailable, attempting DspSource restart")
|
||||||
|
self.dsp.start()
|
||||||
|
|
||||||
|
def onSdrUnavailable(self):
|
||||||
|
logger.debug("received onSdrUnavailable, shutting down DspSource")
|
||||||
|
self.dsp.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class CpuUsageThread(threading.Thread):
|
||||||
|
sharedInstance = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getSharedInstance():
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
CpuUsageThread.sharedInstance = None
|
||||||
|
self.doRun = False
|
||||||
|
self.endEvent.set()
|
||||||
|
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
Metrics.getSharedInstance().addMetric("openwebrx.users", DirectMetric(self.clientCount))
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def broadcast(self):
|
||||||
|
n = self.clientCount()
|
||||||
|
for c in self.clients:
|
||||||
|
c.write_clients(n)
|
||||||
|
|
||||||
|
def addClient(self, client):
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
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()
|
||||||
1
owrx/version.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
openwebrx_version = "v0.18"
|
||||||
239
owrx/websocket.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from multiprocessing import Pipe
|
||||||
|
import select
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OPCODE_TEXT_MESSAGE = 0x01
|
||||||
|
OPCODE_BINARY_MESSAGE = 0x02
|
||||||
|
OPCODE_CLOSE = 0x08
|
||||||
|
OPCODE_PING = 0x09
|
||||||
|
OPCODE_PONG = 0x0A
|
||||||
|
|
||||||
|
|
||||||
|
class IncompleteRead(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Drained(Exception):
|
||||||
|
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):
|
||||||
|
self.handler = handler
|
||||||
|
self.handler.connection.setblocking(0)
|
||||||
|
self.setMessageHandler(messageHandler)
|
||||||
|
(self.interruptPipeRecv, self.interruptPipeSend) = Pipe(duplex=False)
|
||||||
|
self.open = True
|
||||||
|
self.sendLock = threading.Lock()
|
||||||
|
my_headers = self.handler.headers.items()
|
||||||
|
my_header_keys = list(map(lambda x: x[0], my_headers))
|
||||||
|
h_key_exists = lambda x: my_header_keys.count(x)
|
||||||
|
h_value = lambda x: my_headers[my_header_keys.index(x)][1]
|
||||||
|
if (
|
||||||
|
(not h_key_exists("Upgrade"))
|
||||||
|
or not (h_value("Upgrade") == "websocket")
|
||||||
|
or (not h_key_exists("Sec-WebSocket-Key"))
|
||||||
|
):
|
||||||
|
raise WebSocketException
|
||||||
|
ws_key = h_value("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):
|
||||||
|
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):
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# 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(l, n):
|
||||||
|
"""Yield successive n-sized chunks from l."""
|
||||||
|
for i in range(0, len(l), n):
|
||||||
|
yield l[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()
|
||||||
|
else:
|
||||||
|
logger.debug("socket not returned from select; closing")
|
||||||
|
self.close()
|
||||||
|
# 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):
|
||||||
|
self.interruptPipeSend.send(bytes(0x00))
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
WebSocketConnection.connections.append(self)
|
||||||
|
self.open = True
|
||||||
|
while self.open:
|
||||||
|
(read, _, _) = select.select([self.interruptPipeRecv, self.handler.rfile], [], [])
|
||||||
|
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)
|
||||||
|
if mask:
|
||||||
|
data = bytes([b ^ masking_key[index % 4] for (index, b) in enumerate(data)])
|
||||||
|
if opcode == OPCODE_TEXT_MESSAGE:
|
||||||
|
message = data.decode("utf-8")
|
||||||
|
self.messageHandler.handleTextMessage(self, message)
|
||||||
|
elif opcode == OPCODE_BINARY_MESSAGE:
|
||||||
|
self.messageHandler.handleBinaryMessage(self, data)
|
||||||
|
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 TimeoutError:
|
||||||
|
logger.warning("websocket timed out; closing connection")
|
||||||
|
self.open = False
|
||||||
|
|
||||||
|
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 close(self):
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketException(Exception):
|
||||||
|
pass
|
||||||
396
owrx/wsjt.py
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import threading
|
||||||
|
import wave
|
||||||
|
from datetime import datetime, timedelta, date, timezone
|
||||||
|
import time
|
||||||
|
import sched
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
from multiprocessing.connection import Pipe
|
||||||
|
from owrx.map import Map, LocatorLocation
|
||||||
|
import re
|
||||||
|
from queue import Queue, Full
|
||||||
|
from owrx.config import PropertyManager
|
||||||
|
from owrx.bands import Bandplan
|
||||||
|
from owrx.metrics import Metrics, CounterMetric, DirectMetric
|
||||||
|
from owrx.pskreporter import PskReporter
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
class WsjtQueueWorker(threading.Thread):
|
||||||
|
def __init__(self, queue):
|
||||||
|
self.queue = queue
|
||||||
|
self.doRun = True
|
||||||
|
super().__init__(daemon=True)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
while self.doRun:
|
||||||
|
(processor, file) = self.queue.get()
|
||||||
|
try:
|
||||||
|
logger.debug("processing file %s", file)
|
||||||
|
processor.decode(file)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to decode job")
|
||||||
|
self.queue.onError()
|
||||||
|
self.queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
class WsjtQueue(Queue):
|
||||||
|
sharedInstance = None
|
||||||
|
creationLock = threading.Lock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getSharedInstance():
|
||||||
|
with WsjtQueue.creationLock:
|
||||||
|
if WsjtQueue.sharedInstance is None:
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
WsjtQueue.sharedInstance = WsjtQueue(maxsize=pm["wsjt_queue_length"], workers=pm["wsjt_queue_workers"])
|
||||||
|
return WsjtQueue.sharedInstance
|
||||||
|
|
||||||
|
def __init__(self, maxsize, workers):
|
||||||
|
super().__init__(maxsize)
|
||||||
|
metrics = Metrics.getSharedInstance()
|
||||||
|
metrics.addMetric("wsjt.queue.length", DirectMetric(self.qsize))
|
||||||
|
self.inCounter = CounterMetric()
|
||||||
|
metrics.addMetric("wsjt.queue.in", self.inCounter)
|
||||||
|
self.outCounter = CounterMetric()
|
||||||
|
metrics.addMetric("wsjt.queue.out", self.outCounter)
|
||||||
|
self.overflowCounter = CounterMetric()
|
||||||
|
metrics.addMetric("wsjt.queue.overflow", self.overflowCounter)
|
||||||
|
self.errorCounter = CounterMetric()
|
||||||
|
metrics.addMetric("wsjt.queue.error", self.errorCounter)
|
||||||
|
self.workers = [self.newWorker() for _ in range(0, workers)]
|
||||||
|
|
||||||
|
def put(self, item):
|
||||||
|
self.inCounter.inc()
|
||||||
|
try:
|
||||||
|
super(WsjtQueue, self).put(item, block=False)
|
||||||
|
except Full:
|
||||||
|
self.overflowCounter.inc()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get(self, **kwargs):
|
||||||
|
# super.get() is blocking, so it would mess up the stats to inc() first
|
||||||
|
out = super(WsjtQueue, self).get(**kwargs)
|
||||||
|
self.outCounter.inc()
|
||||||
|
return out
|
||||||
|
|
||||||
|
def newWorker(self):
|
||||||
|
worker = WsjtQueueWorker(self)
|
||||||
|
worker.start()
|
||||||
|
return worker
|
||||||
|
|
||||||
|
def onError(self):
|
||||||
|
self.errorCounter.inc()
|
||||||
|
|
||||||
|
|
||||||
|
class WsjtChopper(threading.Thread):
|
||||||
|
def __init__(self, source):
|
||||||
|
self.source = source
|
||||||
|
self.tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"]
|
||||||
|
(self.wavefilename, self.wavefile) = self.getWaveFile()
|
||||||
|
self.switchingLock = threading.Lock()
|
||||||
|
self.scheduler = sched.scheduler(time.time, time.sleep)
|
||||||
|
self.schedulerLock = threading.Lock()
|
||||||
|
(self.outputReader, self.outputWriter) = Pipe()
|
||||||
|
self.doRun = True
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def getWaveFile(self):
|
||||||
|
filename = "{tmp_dir}/openwebrx-wsjtchopper-{id}-{timestamp}.wav".format(
|
||||||
|
tmp_dir=self.tmp_dir, id=id(self), timestamp=datetime.utcnow().strftime(self.fileTimestampFormat)
|
||||||
|
)
|
||||||
|
wavefile = wave.open(filename, "wb")
|
||||||
|
wavefile.setnchannels(1)
|
||||||
|
wavefile.setsampwidth(2)
|
||||||
|
wavefile.setframerate(12000)
|
||||||
|
return filename, wavefile
|
||||||
|
|
||||||
|
def getNextDecodingTime(self):
|
||||||
|
t = datetime.now()
|
||||||
|
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.timestamp()
|
||||||
|
|
||||||
|
def startScheduler(self):
|
||||||
|
self._scheduleNextSwitch()
|
||||||
|
threading.Thread(target=self.scheduler.run).start()
|
||||||
|
|
||||||
|
def emptyScheduler(self):
|
||||||
|
with self.schedulerLock:
|
||||||
|
for event in self.scheduler.queue:
|
||||||
|
self.scheduler.cancel(event)
|
||||||
|
|
||||||
|
def _scheduleNextSwitch(self):
|
||||||
|
with self.schedulerLock:
|
||||||
|
if self.doRun:
|
||||||
|
self.scheduler.enterabs(self.getNextDecodingTime(), 1, self.switchFiles)
|
||||||
|
|
||||||
|
def switchFiles(self):
|
||||||
|
self.switchingLock.acquire()
|
||||||
|
file = self.wavefile
|
||||||
|
filename = self.wavefilename
|
||||||
|
(self.wavefilename, self.wavefile) = self.getWaveFile()
|
||||||
|
self.switchingLock.release()
|
||||||
|
|
||||||
|
file.close()
|
||||||
|
try:
|
||||||
|
WsjtQueue.getSharedInstance().put((self, filename))
|
||||||
|
except Full:
|
||||||
|
logger.warning("wsjt decoding queue overflow; dropping one file")
|
||||||
|
os.unlink(filename)
|
||||||
|
self._scheduleNextSwitch()
|
||||||
|
|
||||||
|
def decoder_commandline(self, file):
|
||||||
|
"""
|
||||||
|
must be overridden in child classes
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def decode(self, file):
|
||||||
|
decoder = subprocess.Popen(
|
||||||
|
self.decoder_commandline(file), stdout=subprocess.PIPE, cwd=self.tmp_dir, preexec_fn=lambda: os.nice(10)
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
line = decoder.stdout.readline()
|
||||||
|
if line is None or (isinstance(line, bytes) and len(line) == 0):
|
||||||
|
break
|
||||||
|
self.outputWriter.send(line)
|
||||||
|
rc = decoder.wait()
|
||||||
|
if rc != 0:
|
||||||
|
logger.warning("decoder return code: %i", rc)
|
||||||
|
os.unlink(file)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
logger.debug("WSJT chopper starting up")
|
||||||
|
self.startScheduler()
|
||||||
|
while self.doRun:
|
||||||
|
data = self.source.read(256)
|
||||||
|
if data is None or (isinstance(data, bytes) and len(data) == 0):
|
||||||
|
self.doRun = False
|
||||||
|
else:
|
||||||
|
self.switchingLock.acquire()
|
||||||
|
self.wavefile.writeframes(data)
|
||||||
|
self.switchingLock.release()
|
||||||
|
|
||||||
|
logger.debug("WSJT chopper shutting down")
|
||||||
|
self.outputReader.close()
|
||||||
|
self.outputWriter.close()
|
||||||
|
self.emptyScheduler()
|
||||||
|
try:
|
||||||
|
os.unlink(self.wavefilename)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("error removing undecoded file")
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
try:
|
||||||
|
return self.outputReader.recv()
|
||||||
|
except EOFError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def decoding_depth(self, mode):
|
||||||
|
pm = PropertyManager.getSharedInstance()
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
class Ft8Chopper(WsjtChopper):
|
||||||
|
def __init__(self, source):
|
||||||
|
self.interval = 15
|
||||||
|
self.fileTimestampFormat = "%y%m%d_%H%M%S"
|
||||||
|
super().__init__(source)
|
||||||
|
|
||||||
|
def decoder_commandline(self, file):
|
||||||
|
return ["jt9", "--ft8", "-d", str(self.decoding_depth("ft8")), file]
|
||||||
|
|
||||||
|
|
||||||
|
class WsprChopper(WsjtChopper):
|
||||||
|
def __init__(self, source):
|
||||||
|
self.interval = 120
|
||||||
|
self.fileTimestampFormat = "%y%m%d_%H%M"
|
||||||
|
super().__init__(source)
|
||||||
|
|
||||||
|
def decoder_commandline(self, file):
|
||||||
|
cmd = ["wsprd"]
|
||||||
|
if self.decoding_depth("wspr") > 1:
|
||||||
|
cmd += ["-d"]
|
||||||
|
cmd += [file]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
class Jt65Chopper(WsjtChopper):
|
||||||
|
def __init__(self, source):
|
||||||
|
self.interval = 60
|
||||||
|
self.fileTimestampFormat = "%y%m%d_%H%M"
|
||||||
|
super().__init__(source)
|
||||||
|
|
||||||
|
def decoder_commandline(self, file):
|
||||||
|
return ["jt9", "--jt65", "-d", str(self.decoding_depth("jt65")), file]
|
||||||
|
|
||||||
|
|
||||||
|
class Jt9Chopper(WsjtChopper):
|
||||||
|
def __init__(self, source):
|
||||||
|
self.interval = 60
|
||||||
|
self.fileTimestampFormat = "%y%m%d_%H%M"
|
||||||
|
super().__init__(source)
|
||||||
|
|
||||||
|
def decoder_commandline(self, file):
|
||||||
|
return ["jt9", "--jt9", "-d", str(self.decoding_depth("jt9")), file]
|
||||||
|
|
||||||
|
|
||||||
|
class Ft4Chopper(WsjtChopper):
|
||||||
|
def __init__(self, source):
|
||||||
|
self.interval = 7.5
|
||||||
|
self.fileTimestampFormat = "%y%m%d_%H%M%S"
|
||||||
|
super().__init__(source)
|
||||||
|
|
||||||
|
def decoder_commandline(self, file):
|
||||||
|
return ["jt9", "--ft4", "-d", str(self.decoding_depth("ft4")), file]
|
||||||
|
|
||||||
|
|
||||||
|
class WsjtParser(object):
|
||||||
|
def __init__(self, handler):
|
||||||
|
self.handler = handler
|
||||||
|
self.dial_freq = None
|
||||||
|
self.band = None
|
||||||
|
|
||||||
|
modes = {"~": "FT8", "#": "JT65", "@": "JT9", "+": "FT4"}
|
||||||
|
|
||||||
|
def parse(self, data):
|
||||||
|
try:
|
||||||
|
msg = data.decode().rstrip()
|
||||||
|
# known debug messages we know to skip
|
||||||
|
if msg.startswith("<DecodeFinished>"):
|
||||||
|
return
|
||||||
|
if msg.startswith(" EOF on input file"):
|
||||||
|
return
|
||||||
|
|
||||||
|
modes = list(WsjtParser.modes.keys())
|
||||||
|
if msg[21] in modes or msg[19] in modes:
|
||||||
|
decoder = Jt9Decoder()
|
||||||
|
else:
|
||||||
|
decoder = WsprDecoder()
|
||||||
|
out = decoder.parse(msg, self.dial_freq)
|
||||||
|
if "mode" in out:
|
||||||
|
self.pushDecode(out["mode"])
|
||||||
|
if "callsign" in out and "locator" in out:
|
||||||
|
Map.getSharedInstance().updateLocation(
|
||||||
|
out["callsign"], LocatorLocation(out["locator"]), out["mode"], self.band
|
||||||
|
)
|
||||||
|
PskReporter.getSharedInstance().spot(out)
|
||||||
|
|
||||||
|
self.handler.write_wsjt_message(out)
|
||||||
|
except ValueError:
|
||||||
|
logger.exception("error 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()
|
||||||
|
|
||||||
|
def setDialFrequency(self, freq):
|
||||||
|
self.dial_freq = freq
|
||||||
|
self.band = Bandplan.getSharedInstance().findBand(freq)
|
||||||
|
|
||||||
|
|
||||||
|
class Decoder(object):
|
||||||
|
def parse_timestamp(self, instring, dateformat):
|
||||||
|
ts = datetime.strptime(instring, dateformat)
|
||||||
|
return int(
|
||||||
|
datetime.combine(datetime.utcnow().date(), ts.time()).replace(tzinfo=timezone.utc).timestamp() * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Jt9Decoder(Decoder):
|
||||||
|
locator_pattern = re.compile("[A-Z0-9]+\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$")
|
||||||
|
|
||||||
|
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'
|
||||||
|
modes = list(WsjtParser.modes.keys())
|
||||||
|
if msg[19] in modes:
|
||||||
|
dateformat = "%H%M"
|
||||||
|
else:
|
||||||
|
dateformat = "%H%M%S"
|
||||||
|
timestamp = self.parse_timestamp(msg[0 : len(dateformat)], dateformat)
|
||||||
|
msg = msg[len(dateformat) + 1 :]
|
||||||
|
modeChar = msg[14:15]
|
||||||
|
mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown"
|
||||||
|
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]),
|
||||||
|
"mode": mode,
|
||||||
|
"msg": wsjt_msg,
|
||||||
|
}
|
||||||
|
result.update(self.parseMessage(wsjt_msg))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def parseMessage(self, msg):
|
||||||
|
m = Jt9Decoder.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(2) == "RR73":
|
||||||
|
return {"callsign": m.group(1)}
|
||||||
|
return {"callsign": m.group(1), "locator": m.group(2)}
|
||||||
|
|
||||||
|
|
||||||
|
class WsprDecoder(Decoder):
|
||||||
|
wspr_splitter_pattern = re.compile("([A-Z0-9]*)\\s([A-R]{2}[0-9]{2})\\s([0-9]+)")
|
||||||
|
|
||||||
|
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'
|
||||||
|
wsjt_msg = msg[29:].strip()
|
||||||
|
result = {
|
||||||
|
"timestamp": self.parse_timestamp(msg[0:4], "%H%M"),
|
||||||
|
"db": float(msg[5:8]),
|
||||||
|
"dt": float(msg[9:13]),
|
||||||
|
"freq": dial_freq + int(float(msg[14:24]) * 1e6),
|
||||||
|
"drift": int(msg[25:28]),
|
||||||
|
"mode": "WSPR",
|
||||||
|
"msg": wsjt_msg,
|
||||||
|
}
|
||||||
|
result.update(self.parseMessage(wsjt_msg))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def parseMessage(self, msg):
|
||||||
|
m = WsprDecoder.wspr_splitter_pattern.match(msg)
|
||||||
|
if m is None:
|
||||||
|
return {}
|
||||||
|
return {"callsign": m.group(1), "locator": m.group(2)}
|
||||||
8
push.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
|
||||||
|
for image in openwebrx-rtlsdr openwebrx-sdrplay openwebrx-hackrf openwebrx-airspy openwebrx-full openwebrx; do
|
||||||
|
docker push jketterl/$image:$ARCH
|
||||||
|
done
|
||||||
171
rxws.py
@@ -1,171 +0,0 @@
|
|||||||
"""
|
|
||||||
rxws: WebSocket methods implemented 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 <randras@sdr.hu>
|
|
||||||
|
|
||||||
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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import sha
|
|
||||||
import select
|
|
||||||
import code
|
|
||||||
|
|
||||||
class WebSocketException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def handshake(myself):
|
|
||||||
my_client_id=myself.path[4:]
|
|
||||||
my_headers=myself.headers.items()
|
|
||||||
my_header_keys=map(lambda x:x[0],my_headers)
|
|
||||||
h_key_exists=lambda x:my_header_keys.count(x)
|
|
||||||
h_value=lambda x:my_headers[my_header_keys.index(x)][1]
|
|
||||||
#print "The Lambdas(tm)"
|
|
||||||
#print h_key_exists("upgrade")
|
|
||||||
#print h_value("upgrade")
|
|
||||||
#print h_key_exists("sec-websocket-key")
|
|
||||||
if (not h_key_exists("upgrade")) or not (h_value("upgrade")=="websocket") or (not h_key_exists("sec-websocket-key")):
|
|
||||||
raise WebSocketException
|
|
||||||
ws_key=h_value("sec-websocket-key")
|
|
||||||
ws_key_toreturn=base64.b64encode(sha.new(ws_key+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest())
|
|
||||||
#A sample list of keys we get: [('origin', 'http://localhost:8073'), ('upgrade', 'websocket'), ('sec-websocket-extensions', 'x-webkit-deflate-frame'), ('sec-websocket-version', '13'), ('host', 'localhost:8073'), ('sec-websocket-key', 't9J1rgy4fc9fg2Hshhnkmg=='), ('connection', 'Upgrade'), ('pragma', 'no-cache'), ('cache-control', 'no-cache')]
|
|
||||||
myself.wfile.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "+ws_key_toreturn+"\r\nCQ-CQ-de: HA5KFU\r\n\r\n")
|
|
||||||
|
|
||||||
def get_header(size):
|
|
||||||
#this does something similar: https://github.com/lemmingzshadow/php-websocket/blob/master/server/lib/WebSocket/Connection.php
|
|
||||||
ws_first_byte=0b10000010 # FIN=1, OP=2
|
|
||||||
if(size>125):
|
|
||||||
ws_second_byte=126 # The following two bytes will indicate frame size
|
|
||||||
extended_size=chr((size>>8)&0xff)+chr(size&0xff) #Okay, it uses reverse byte order (little-endian) compared to anything else sent on TCP
|
|
||||||
else:
|
|
||||||
ws_second_byte=size
|
|
||||||
#256 bytes binary message in a single unmasked frame | 0x82 0x7E 0x0100 [256 bytes of binary data]
|
|
||||||
extended_size=""
|
|
||||||
return chr(ws_first_byte)+chr(ws_second_byte)+extended_size
|
|
||||||
|
|
||||||
def code_payload(data, masking_key=""):
|
|
||||||
# both encode or decode
|
|
||||||
if masking_key=="":
|
|
||||||
key = (61, 84, 35, 6)
|
|
||||||
else:
|
|
||||||
key = [ord(i) for i in masking_key]
|
|
||||||
encoded=""
|
|
||||||
for i in range(0,len(data)):
|
|
||||||
encoded+=chr(ord(data[i])^key[i%4])
|
|
||||||
return encoded
|
|
||||||
|
|
||||||
def xxdg(data):
|
|
||||||
output=""
|
|
||||||
for i in range(0,len(data)/8):
|
|
||||||
output+=xxd(data[i:i+8])
|
|
||||||
if i%2: output+="\n"
|
|
||||||
else: output+=" "
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def xxd(data):
|
|
||||||
#diagnostic purposes only
|
|
||||||
output=""
|
|
||||||
for d in data:
|
|
||||||
output+=hex(ord(d))[2:].zfill(2)+" "
|
|
||||||
return output
|
|
||||||
|
|
||||||
#for R/W the WebSocket, use recv/send
|
|
||||||
#for reading the TCP socket, use readsock
|
|
||||||
#for writing the TCP socket, use myself.wfile.write and flush
|
|
||||||
|
|
||||||
def readsock(myself,size,blocking):
|
|
||||||
#http://thenestofheliopolis.blogspot.hu/2011/01/how-to-implement-non-blocking-two-way.html
|
|
||||||
if blocking:
|
|
||||||
return myself.rfile.read(size)
|
|
||||||
else:
|
|
||||||
poll = select.poll()
|
|
||||||
poll.register(myself.rfile.fileno(), select.POLLIN or select.POLLPRI)
|
|
||||||
fd = poll.poll(0) #timeout is 0
|
|
||||||
if len(fd):
|
|
||||||
f = fd[0]
|
|
||||||
if f[1] > 0:
|
|
||||||
return myself.rfile.read(size)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def recv(myself, blocking=False, debug=False):
|
|
||||||
bufsize=70000
|
|
||||||
#myself.connection.setblocking(blocking) #umm... we cannot do that with rfile
|
|
||||||
if debug: print "ws_recv begin"
|
|
||||||
try:
|
|
||||||
data=readsock(myself,6,blocking)
|
|
||||||
#print "rxws.recv bytes:",xxd(data)
|
|
||||||
except:
|
|
||||||
if debug: print "ws_recv error"
|
|
||||||
return ""
|
|
||||||
if debug: print "ws_recv recved"
|
|
||||||
if(len(data)==0): return ""
|
|
||||||
fin=ord(data[0])&128!=0
|
|
||||||
is_text_frame=ord(data[0])&15==1
|
|
||||||
length=ord(data[1])&0x7f
|
|
||||||
data+=readsock(myself,length,blocking)
|
|
||||||
#print "rxws.recv length is ",length," (multiple packets together?) len(data) =",len(data)
|
|
||||||
has_one_byte_length=length<125
|
|
||||||
masked=ord(data[1])&0x80!=0
|
|
||||||
#print "len=", length, len(data)-2
|
|
||||||
#print "fin, is_text_frame, has_one_byte_length, masked = ", (fin, is_text_frame, has_one_byte_length, masked)
|
|
||||||
#print xxd(data)
|
|
||||||
if fin and is_text_frame and has_one_byte_length:
|
|
||||||
if masked:
|
|
||||||
return code_payload(data[6:], data[2:6])
|
|
||||||
else:
|
|
||||||
return data[2:]
|
|
||||||
|
|
||||||
#Useful links for ideas on WebSockets:
|
|
||||||
# http://stackoverflow.com/questions/8125507/how-can-i-send-and-receive-websocket-messages-on-the-server-side
|
|
||||||
# https://developer.mozilla.org/en-US/docs/WebSockets/Writing_WebSocket_server
|
|
||||||
# http://tools.ietf.org/html/rfc6455#section-5.2
|
|
||||||
|
|
||||||
|
|
||||||
def flush(myself):
|
|
||||||
myself.wfile.flush()
|
|
||||||
#or the socket, not the rfile:
|
|
||||||
#lR,lW,lX = select.select([],[myself.connection,],[],60)
|
|
||||||
|
|
||||||
|
|
||||||
def send(myself, data, begin_id="", debug=0):
|
|
||||||
base_frame_size=35000 #could guess by MTU?
|
|
||||||
debug=0
|
|
||||||
#try:
|
|
||||||
while True:
|
|
||||||
counter=0
|
|
||||||
from_end=len(data)-counter
|
|
||||||
if from_end+len(begin_id)>base_frame_size:
|
|
||||||
data_to_send=begin_id+data[counter:counter+base_frame_size-len(begin_id)]
|
|
||||||
header=get_header(len(data_to_send))
|
|
||||||
flush(myself)
|
|
||||||
myself.wfile.write(header+data_to_send)
|
|
||||||
flush(myself)
|
|
||||||
if debug: print "rxws.send ==================== #1 if branch :: from={0} to={1} dlen={2} hlen={3}".format(counter,counter+base_frame_size-len(begin_id),len(data_to_send),len(header))
|
|
||||||
else:
|
|
||||||
data_to_send=begin_id+data[counter:]
|
|
||||||
header=get_header(len(data_to_send))
|
|
||||||
flush(myself)
|
|
||||||
myself.wfile.write(header+data_to_send)
|
|
||||||
flush(myself)
|
|
||||||
if debug: print "rxws.send :: #2 else branch :: dlen={0} hlen={1}".format(len(data_to_send),len(header))
|
|
||||||
#if debug: print "header:\n"+xxdg(header)+"\n\nws data:\n"+xxdg(data_to_send)
|
|
||||||
break
|
|
||||||
counter+=base_frame_size-len(begin_id)
|
|
||||||
#except:
|
|
||||||
# pass
|
|
||||||
33
sdrhu.py
@@ -20,31 +20,12 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import config_webrx as cfg, time, subprocess
|
from owrx.sdrhu import SdrHuUpdater
|
||||||
|
from owrx.config import PropertyManager
|
||||||
|
|
||||||
def run(continuously=True):
|
if __name__ == "__main__":
|
||||||
if not cfg.sdrhu_key: return
|
pm = PropertyManager.getSharedInstance().loadConfig("config_webrx")
|
||||||
firsttime="(Your receiver is soon getting listed on sdr.hu!)"
|
|
||||||
while True:
|
|
||||||
cmd = "wget --timeout=15 -4qO- https://sdr.hu/update --post-data \"url=http://"+cfg.server_hostname+":"+str(cfg.web_port)+"&apikey="+cfg.sdrhu_key+"\" 2>&1"
|
|
||||||
print "[openwebrx-sdrhu]", cmd
|
|
||||||
returned=subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate()
|
|
||||||
returned=returned[0]
|
|
||||||
#print returned
|
|
||||||
if "UPDATE:" in returned:
|
|
||||||
retrytime_mins = 20
|
|
||||||
value=returned.split("UPDATE:")[1].split("\n",1)[0]
|
|
||||||
if value.startswith("SUCCESS"):
|
|
||||||
print "[openwebrx-sdrhu] Update succeeded! "+firsttime
|
|
||||||
firsttime=""
|
|
||||||
else:
|
|
||||||
print "[openwebrx-sdrhu] Update failed, your receiver cannot be listed on sdr.hu! Reason:", value
|
|
||||||
else:
|
|
||||||
retrytime_mins = 2
|
|
||||||
print "[openwebrx-sdrhu] wget failed while updating, your receiver cannot be listed on sdr.hu!"
|
|
||||||
if not continuously: break
|
|
||||||
time.sleep(60*retrytime_mins)
|
|
||||||
|
|
||||||
if __name__=="__main__":
|
|
||||||
run(False)
|
|
||||||
|
|
||||||
|
if not "sdrhu_key" in pm:
|
||||||
|
exit(1)
|
||||||
|
SdrHuUpdater().update()
|
||||||
|
|||||||