47 Commits

Author SHA1 Message Date
Alessandro
808a63f57c Update v3.1.0 release date in changelog 2019-10-21 23:45:37 +02:00
Marco Dalla Tiezza
e03d2f51ba Updated GLIBC reequirements for Linux 2019-10-20 22:09:36 +02:00
AresValley
0385f7324c Solved minor bu in windows shell script (wrong folder structure) 2019-10-18 16:24:00 +02:00
marbort
7ac15e7d45 macOS specfile minor changes
Removed unnecessary parenthesis in specfile for macOS that prevented successful building of .app package
2019-10-17 12:33:30 +02:00
Alessandro
71d898c966 Fix 'unreleased' link in CHANGELOG.md 2019-10-16 21:20:02 +02:00
Alessandro
e062d0befd Update the changelog to the version 3.1.0 2019-10-15 19:17:47 +02:00
Alessandro
8e79bf6adf Fix a bug in the *.spec files and also apply the following major changes:
- Add support for adding the base folder to PATH.
- Only display one pop up window at a time in order to avoid confusion.
- Add automatic updates feature:
	- Windows and Linux versions will be shipped with an updater program used to
	  update both Artemis and the updater itself.
	- MacOs versions will not have the updater. Instead the user will be asked
	  to download the new software version (if present) via browser.
2019-10-15 19:14:29 +02:00
Alessandro
08b3312b23 Ensure compatibility of the new version with old database format (ACF value structure) 2019-09-08 15:58:09 +02:00
Alessandro
0606549a7d Merge branch 'change_layout' 2019-09-08 15:26:11 +02:00
Alessandro
643e6c7457 Merge branch 'minor_improvements' (fix conflicts on readme files) 2019-09-08 15:25:22 +02:00
Alessandro
44747464da Add a GitHub link. Also improve .spec files (no need to copy
source files). Also distinguish from running binary or script
in the displayed version.
2019-09-07 23:34:58 +02:00
Marco
e36f04f70b Readme fix 2019-09-05 09:42:48 +02:00
Marco
9560f8747f Merge branch 'readme_3.0.2' 2019-09-05 09:27:48 +02:00
Alessandro
40e2e01088 Move main and filter tab at an inner level 2019-09-04 20:08:57 +02:00
Alessandro
1509e04c93 Closes #9 Add support for complex/multiple ACF values 2019-09-04 19:49:16 +02:00
Marco Dalla Tiezza
d30a9b7aa9 GLIBC minimum requirements, ACF details on variable values 2019-09-02 00:17:32 +02:00
Alessandro
4a54ef54cb Major refactor of filters and spaceweather 2019-09-01 12:28:31 +02:00
Marco Dalla Tiezza
d37c8e96d6 Updated thanks 2019-08-31 21:17:35 +02:00
Marco Dalla Tiezza
4855b232d8 ACF Update 2019-08-31 21:00:31 +02:00
Alessandro
6870774577 Update readme 2019-08-09 18:50:20 +02:00
Alessandro
461da37fdf Set correct sizes for all the audio buttons for high res screens 2019-08-09 18:41:52 +02:00
Alessandro
7e897bbb82 Fix #12 An audio sample can be paused and a different one can be
played without a program crash
2019-08-04 11:12:58 +02:00
Marco
1db7f14a27 Removed empty item in a list 2019-08-03 23:39:26 +02:00
Marco
fd1c2a62f1 Typo fixing 2019-08-03 23:37:58 +02:00
Marco
cceed86859 Merge branch 'One_dir_specs' 2019-08-03 23:35:43 +02:00
Marco
ec35400087 Updated README 2019-08-03 23:35:05 +02:00
Marco
beb94d9d07 One-Dir spec files for Windows and Linux 2019-08-03 23:20:23 +02:00
alessandro90
047a612f19 Update requirements
(cherry picked from commit 791cdbff987fbee2bae1e31b348b256ead3cd1d1)
2019-08-01 17:49:24 +02:00
alessandro90
5ae02456ba Fix changelog typo 2019-08-01 16:48:44 +02:00
alessandro90
6e234a1530 Add a changelog 2019-08-01 15:33:56 +02:00
Marco
f5bad77a36 Linux deploy script bug fix 2019-08-01 01:28:26 +02:00
alessandro90
d386555c16 Loop button for audio samples always enabled
(cherry picked from commit 09afe6b016342fe36b7e306f863f581dcd65cb18)
2019-07-31 23:33:42 +02:00
Marco
0d2d395639 Merge remote-tracking branch 'origin/audio_loop' 2019-07-31 23:16:14 +02:00
alessandro90
03f0f7a81f Easier way to change the displayed release version 2019-07-31 22:58:27 +02:00
alessandro90
f23359f3cb Close #3 Implement a loop functionality for audio samples 2019-07-31 22:39:04 +02:00
alessandro90
0cc99f0ac9 Fix #7 Show maximized window at startup 2019-07-31 19:47:54 +02:00
Marco
dbae83eb89 Changed few URL protocol to the 'over SSL' version
(cherry picked from commit 7db82fd09efc6f66365057777c0f10e7a51cdcc2)
2019-07-31 19:40:23 +02:00
alessandro90
fecadb132f Merge branch 'SSL_test' 2019-07-31 19:37:53 +02:00
alessandro90
e5e80b693e Fix #6 Add cacert.pem also for async downloads 2019-07-31 19:36:04 +02:00
Marco
d1699db5a6 Merge branch 'SSL_fix' 2019-07-29 15:33:18 +02:00
alessandro90
0735039213 Remove other unused imports 2019-07-24 22:06:37 +02:00
alessandro90
09abbf5d0c Remove unused import 2019-07-24 21:55:37 +02:00
alessandro90
0732dcb816 Always include cacert in get_pool_manager 2019-07-24 21:08:24 +02:00
alessandro90
0e6c826ac2 Add full cacert support for all platforms also for the checksum.
Distinguish compiled and script case. Adjust .spec files
2019-07-24 20:45:20 +02:00
Marco
1a35d12609 Urllib3 downgrade to 1.24.3 for Request compatibility 2019-07-24 18:26:34 +02:00
Marco
0fa4a40869 Urllib3 SSL secure connection with server. Works with all OSs 2019-07-24 18:25:28 +02:00
Marco
dcf726a72a Added SSL certificates into the bundle 2019-07-24 18:17:38 +02:00
41 changed files with 12330 additions and 6087 deletions

6
.gitignore vendored
View File

@@ -1,7 +1,9 @@
__pycache__ __pycache__
Data Data
csv_info.txt src/themes/__current_theme
src/themes/.current_theme
designer.bat designer.bat
launch.bat launch.bat
.vscode/ .vscode/
.code-workspace
spec_files/**/output
*.txt

42
CHANGELOG.md Normal file
View File

@@ -0,0 +1,42 @@
# Changelog
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and the format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
The first release is [3.0.0] because this is actually the third major version (completely rewritten) of the software.
## [Unreleased]
...
## [3.1.0] - 2019-10-21
### Added
- Automatic updates. From this version Artemis can update itself if a new version is available. Works only when running the executable version (disabled when running from source). The feature is partially unavailable for Mac, you can only download the new version.
- The software version displayed has now a `.Dev` appended when running from script (_e.g._ 3.1.0.Dev) to differentiate from an actual binary executable. The `.Dev` thus implies that the running version of the software could not correspond to a particular release.
- The `*.spec` files can be executed without copying the source code into
their folder.
- Add a link to the GitHub repository in the action bar.
- Add support for signals with multiple-value acf ([#9](https://github.com/AresValley/Artemis/pull/9)). This partially breaks the backward compatibility because the database changed structure.
### Fixed
- Adding the `Artemis` folder to `PATH` as the expected behaviour. Prior to this fix, Artemis could not find the `Data` and `themes` folders if started from outside the `Artemis` folder.
- The audio buttons are of the same dimension also for high resolution screens ([#13](https://github.com/AresValley/Artemis/pull/13))
- An audio sample can be paused and a different one can be played without a program crash ([#12](https://github.com/AresValley/Artemis/pull/12))
## [3.0.1] - 2019-8-1
### Added
- The audio player has now a loop button ([#3](https://github.com/AresValley/Artemis/pull/3)).
- The project has now a Changelog file.
### Fixed
- Added SSL certificates for all downloads. Avoid a crash of the program for certain systems ([#6](https://github.com/AresValley/Artemis/pull/6)).
- Start the application in maximized mode. The label in the propagation data are well displayed ([#7](https://github.com/AresValley/Artemis/pull/7)).
- Compile the executable for Linux on an older version to avoid GLIBC compatibilities issues ([#8](https://github.com/AresValley/Artemis/pull/8)).
## [3.0.0] - 2019-07-23
First release.
<!-- Links definitions -->
[Unreleased]: https://github.com/AresValley/Artemis/compare/v3.1.0...HEAD
[3.1.0]: https://github.com/AresValley/Artemis/compare/v3.0.1...v3.1.0
[3.0.1]: https://github.com/AresValley/Artemis/compare/v3.0.0...v3.0.1
[3.0.0]: https://github.com/AresValley/Artemis/releases/tag/v3.0.0

View File

@@ -12,7 +12,8 @@ In short, ARTEMIS is a signals hunter software and a useful aid for radio listen
- [Run the software](#Run-the-software) - [Run the software](#Run-the-software)
- [Run from binary](#Run-from-binary) - [Run from binary](#Run-from-binary)
- [Run from source code](#Run-from-source-code-OS-independent) - [Run from source code](#Run-from-source-code)
- [Compile from source code](#Compile-from-source-code)
- [Database](#database) - [Database](#database)
- [Syntax](#syntax) - [Syntax](#syntax)
- [Multiple Items fields (Location, Modulation)](#multiple-items-fields-location-modulation) - [Multiple Items fields (Location, Modulation)](#multiple-items-fields-location-modulation)
@@ -28,10 +29,17 @@ Artemis 3 is entirely written in Python, so if you already have Python 3.7.0+ in
**If you don't know what you want or you are not sure where to look, this is for you.** **If you don't know what you want or you are not sure where to look, this is for you.**
Basically, this is the easiest, smooth, and clean way to run Artemis 3. A Python installation is not required. Basically, this is the easiest, smooth, and clean way to run Artemis 3. A Python installation is not required.
For more information, follow [the main page of Artemis 3](https://aresvalley.com/artemis/) (detailed documentation at the end of the main page) For more information, follow [the main page of Artemis 3](https://aresvalley.com/artemis/) (detailed documentation at the end of the main page).
**Requirements:**
- Windows 7/8/8.1/10
- Linux: Ubuntu 18.04+, Mint 19+, Fedora 28+ and many other. **You need at least version 2.27 of the GLIBC system library** ([details](https://github.com/AresValley/Artemis/tree/master/spec_files))
- macOS
### Run from source code ### Run from source code
Run the software from the source code with the Python interpreter is the simplest and natural way to run Artemis 3. Requirements: Run the software from the source code with the Python interpreter is the simplest and natural way to run Artemis 3.
**Requirements:**
- Python (ver. 3.7.0+) - Python (ver. 3.7.0+)
- Python libraries (in `requirements/requirements.txt`) - Python libraries (in `requirements/requirements.txt`)
@@ -49,6 +57,9 @@ pip install -r requirements.txt --user
python3 artemis.py python3 artemis.py
``` ```
### Compile from source code
If you want to compile Artemis yourself from the source code follow the instructions in the [spec_files/README](spec_files/README.md) file.
## Database ## Database
The database (db.csv) is directly extracted from sigidwiki.com with a DB parser and reworked to a standard format defined as follow. Artemis DB is a human-readable csv file where the delimiter is the character `*` (Asterisk, Unicode: U+002A). The new entry (separation between signals) is the End Of Line (EOL) escape sequence `\n`. Every signal is directly connected to spectra and audio sample stored in **Spectra** and **Audio** folders, respectively. Every signal is composed of 12 columns: The database (db.csv) is directly extracted from sigidwiki.com with a DB parser and reworked to a standard format defined as follow. Artemis DB is a human-readable csv file where the delimiter is the character `*` (Asterisk, Unicode: U+002A). The new entry (separation between signals) is the End Of Line (EOL) escape sequence `\n`. Every signal is directly connected to spectra and audio sample stored in **Spectra** and **Audio** folders, respectively. Every signal is composed of 12 columns:
@@ -66,7 +77,7 @@ The database (db.csv) is directly extracted from sigidwiki.com with a DB parser
| 9 | Description | - | - | string | | 9 | Description | - | - | string |
| 10 | Modulation | - | ✔ | string | | 10 | Modulation | - | ✔ | string |
| 11 | ID Code | - | - | integer | | 11 | ID Code | - | - | integer |
| 12 | Auto-correlation function | ms | - | string | | 12 | Auto-correlation function | ms | | string |
### Syntax ### Syntax
@@ -99,17 +110,35 @@ The database (db.csv) is directly extracted from sigidwiki.com with a DB parser
|:-:|:-:|:-:|:-:|:-:|:-:|:-:| |:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| Trunked | Utility | Sat | Navigation | Interfering | Number Stations | Time Signal | | Trunked | Utility | Sat | Navigation | Interfering | Number Stations | Time Signal |
12. **Auto-correlation funtion (ACF)**: The ACF is an awesome discriminator when the signal is composed of redundant pattern that continouosly repeats. It is reported in **ms**. An extended description with an example signal analysis is available here: https://aresvalley.com/documentation/ 12. **Auto-correlation funtion (ACF)**: The ACF is an awesome discriminator when the signal is composed of redundant pattern that continouosly repeats. Unfortunately, for this reason, ACF is not always available. The time value is reported in **ms** and, in some cases, could have multiple values for a single signal. An extended description with an example signal analysis is available here: https://aresvalley.com/documentation/
```
... ID Code * ACF1 Description - ACF1 Value (in ms) ; ACF2 Description - ACF2 Value (in ms) ; ...
```
**Example 1 (D-STAR):**
```
... ID Code * Superframe - 420 ; Frame - 20
```
**Example 2 (EDACS):**
```
... ID Code * edacs48 - 60 ; edacs96 - 30
```
**Special case:** variable ACFs are allowed and a brief explanation can be reported instead of ACF Value. A nice example is the * [SSTV](https://www.sigidwiki.com/wiki/Slow-Scan_Television_(SSTV) "SSTV") transmission where the ACF is directly related to the number of lines per minute.
### Multiple Items fields (Location, Modulation) ### Multiple Items fields (Location, Modulation)
The necessity to manage a multiple Location/Modulation search pushed us to implement a fictitious 'secondary delimiter' chosen to be the `;` character. For instance: The necessity to manage a multiple Location/Modulation search pushed us to implement a fictitious 'secondary delimiter' chosen to be the `;` character. For instance:
``` ```
Band. Upper Limit * Location 1 ; Location 2 ; ... * sigidwiki URL ... Band. Upper Limit * Location 1 ; Location 2 ; ... * sigidwiki URL ...
``` ```
or or
``` ```
Description * Modulation 1 ; Modulation 2 ; ... * ID Code ... Description * Modulation 1 ; Modulation 2 ; ... * ID Code ...
``` ```
## Themes ## Themes
@@ -129,6 +158,6 @@ You should have received a copy of the GNU General Public License along with thi
* [**Alessandro Ceccato**](https://github.com/alessandro90 "GitHub profile") - *Artemis III lead developer* * [**Alessandro Ceccato**](https://github.com/alessandro90 "GitHub profile") - *Artemis III lead developer*
* **Paolo Romani (IZ1MLL)** - *Lead β Tester, RF specialist* * **Paolo Romani (IZ1MLL)** - *Lead β Tester, RF specialist*
* **Carl Colena** - *Sigidwiki admin, β Tester, Signals expert* * **Carl Colena** - *Sigidwiki admin, β Tester, Signals expert*
* **Marco Bortoli** - *macOS deployment, β Tester* * [**Marco Bortoli**](https://github.com/marbort "GitHub profile") - *macOS deployment, β Tester*
* **Pierpaolo Pravatto** - *Wiki page, β Tester* * [**Pierpaolo Pravatto**](https://github.com/ppravatto "GitHub profile") - *Wiki page, β Tester*
* **Francesco Capostagno, Luca, Pietro** - *β Tester* * [**Francesco Capostagno**](https://github.com/fcapostagno "GitHub profile"), **Luca**, **Pietro** - *β Tester*

View File

@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}

View File

@@ -1,7 +1,8 @@
numpy==1.17.2
pandas>=0.24.2 pandas>=0.24.2
certifi>=2019.6.16 certifi>=2019.6.16
aiohttp>=3.5.4 aiohttp>=3.5.4
urllib3>=1.25.3 urllib3==1.24.3
pygame>=1.9.6 pygame>=1.9.6
QtAwesome>=0.5.7 QtAwesome>=0.6.0
PyQt5==5.12.2 PyQt5==5.12.2

View File

@@ -1,12 +1,21 @@
# -*- mode: python -*- # -*- mode: python -*-
import glob
import os
block_cipher = None block_cipher = None
import glob, os
data_file = [(f, '.') for f in glob.glob('*.[pu][yi]') if f != "artemis.py"] SRC_PATH = "../../src/"
a = Analysis(['artemis.py'], data_file = [
(f, '.') for f in glob.glob(SRC_PATH + '*.[pu][yi]')
if f.split('/')[-1] != "artemis.py" and f.split('/')[-1] != "updater.py"
]
data_file.append((SRC_PATH + 'cacert.pem', '.'))
a = Analysis([SRC_PATH + 'artemis.py'], # noqa: 821
pathex=[os.getcwd()], pathex=[os.getcwd()],
binaries=[], binaries=[],
datas=data_file, datas=data_file,
@@ -18,9 +27,10 @@ a = Analysis(['artemis.py'],
win_private_assemblies=False, win_private_assemblies=False,
cipher=block_cipher, cipher=block_cipher,
noarchive=False) noarchive=False)
pyz = PYZ(a.pure, a.zipped_data, pyz = PYZ(a.pure, # noqa: 821
cipher=block_cipher) a.zipped_data,
exe = EXE(pyz, cipher=block_cipher)
exe = EXE(pyz, # noqa: 821
a.scripts, a.scripts,
a.binaries, a.binaries,
a.zipfiles, a.zipfiles,

View File

@@ -0,0 +1,49 @@
# -*- mode: python -*-
import glob
import os
block_cipher = None
SRC_PATH = "../../src/"
data_file = [
(f, '.') for f in glob.glob(SRC_PATH + '*.[pu][yi]')
if f.split('/')[-1] != "artemis.py"
]
data_file.append((SRC_PATH + 'cacert.pem', '.'))
a = Analysis([SRC_PATH + 'artemis.py'], # noqa: 821
pathex=[os.getcwd()],
binaries=[],
datas=data_file,
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, # noqa: 821
a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz, # noqa: 821
a.scripts,
[],
exclude_binaries=True,
name='Artemis',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False)
coll = COLLECT(exe, # noqa: 821
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name='Artemis')

42
spec_files/Linux/build.sh Normal file
View File

@@ -0,0 +1,42 @@
echo "Build Artemis executable.."
rm -rf output
mkdir output
mkdir output/artemis
pyinstaller Artemis.spec
mv -v ./dist/Artemis ./output/Artemis
rm -rfv dist build
echo "Build _ArtemisUpdater.."
pyinstaller updater.spec
mv -v ./dist/_ArtemisUpdater ./output/_ArtemisUpdater
rm -rfv dist build
echo "Create single archives"
cd output
cp -r ../../../src/themes artemis/themes
rm -f artemis/themes/__current_theme
cp Artemis artemis/Artemis
cp _ArtemisUpdater artemis/_ArtemisUpdater
tar -czvf Artemis_linux.tar.gz Artemis -C artemis themes
tar -czvf _ArtemisUpdater_linux.tar.gz ./_ArtemisUpdater
echo "Create full archive for website"
cp ../artemis3.svg artemis
cp ../create_shortcut.sh artemis
tar -czvf ArtemisWebDownlaod_linux.tar.gz artemis
echo "Get size and sha256"
python ../../__get_hash_code.py Artemis_linux.tar.gz _ArtemisUpdater_linux.tar.gz ArtemisWebDownlaod_linux.tar.gz
cd ..
echo "Done."

View File

@@ -23,12 +23,12 @@ if [ -e "$file" ]; then
read -p "" doit read -p "" doit
case $doit in case $doit in
u|U) u|U)
echo "#!/usr/bin/env xdg-open" > /home/$USER/.local/share/applications/artemis.desktop
echo "[Desktop Entry]" >> /home/$USER/.local/share/applications/artemis.desktop echo "[Desktop Entry]" >> /home/$USER/.local/share/applications/artemis.desktop
echo "Name=Artemis" >> /home/$USER/.local/share/applications/artemis.desktop echo "Name=Artemis" >> /home/$USER/.local/share/applications/artemis.desktop
echo "Type=Application" >> /home/$USER/.local/share/applications/artemis.desktop
echo "StartupWMClass=artemis3" >> /home/$USER/.local/share/applications/artemis.desktop echo "StartupWMClass=artemis3" >> /home/$USER/.local/share/applications/artemis.desktop
echo "Exec=sh -c 'cd $DIR && ./artemis' " >> /home/$USER/.local/share/applications/artemis.desktop echo "Exec=sh -c \"cd $DIR && ./Artemis\" " >> /home/$USER/.local/share/applications/artemis.desktop
echo "Terminal=False" >> /home/$USER/.local/share/applications/artemis.desktop echo "Terminal=false" >> /home/$USER/.local/share/applications/artemis.desktop
echo "Icon=artemis3" >> /home/$USER/.local/share/applications/artemis.desktop echo "Icon=artemis3" >> /home/$USER/.local/share/applications/artemis.desktop
sudo cp ./artemis3.svg /usr/share/icons/ sudo cp ./artemis3.svg /usr/share/icons/
echo "Link Updated!" echo "Link Updated!"
@@ -41,12 +41,12 @@ if [ -e "$file" ]; then
*) echo "Sorry! Invalid option $REPLY";; *) echo "Sorry! Invalid option $REPLY";;
esac esac
else else
echo "#!/usr/bin/env xdg-open" > /home/$USER/.local/share/applications/artemis.desktop
echo "[Desktop Entry]" >> /home/$USER/.local/share/applications/artemis.desktop echo "[Desktop Entry]" >> /home/$USER/.local/share/applications/artemis.desktop
echo "Name=Artemis" >> /home/$USER/.local/share/applications/artemis.desktop echo "Name=Artemis" >> /home/$USER/.local/share/applications/artemis.desktop
echo "Type=Application" >> /home/$USER/.local/share/applications/artemis.desktop
echo "StartupWMClass=artemis3" >> /home/$USER/.local/share/applications/artemis.desktop echo "StartupWMClass=artemis3" >> /home/$USER/.local/share/applications/artemis.desktop
echo "Exec=sh -c 'cd $DIR && ./artemis' " >> /home/$USER/.local/share/applications/artemis.desktop echo "Exec=sh -c \"cd $DIR && ./Artemis\" " >> /home/$USER/.local/share/applications/artemis.desktop
echo "Terminal=False" >> /home/$USER/.local/share/applications/artemis.desktop echo "Terminal=false" >> /home/$USER/.local/share/applications/artemis.desktop
echo "Icon=artemis3" >> /home/$USER/.local/share/applications/artemis.desktop echo "Icon=artemis3" >> /home/$USER/.local/share/applications/artemis.desktop
sudo cp ./artemis3.svg /usr/share/icons/ sudo cp ./artemis3.svg /usr/share/icons/
echo " echo "

View File

@@ -0,0 +1,51 @@
# -*- mode: python ; coding: utf-8 -*-
import os
BASE_FOLDER = "../../src/"
block_cipher = None
data_file = [
(os.path.join(BASE_FOLDER, "download_db_window.ui"), "."),
(os.path.join(BASE_FOLDER, "download_window.py"), "."),
(os.path.join(BASE_FOLDER, "utilities.py"), "."),
(os.path.join(BASE_FOLDER, "versioncontroller.py"), "."),
(os.path.join(BASE_FOLDER, "downloadtargetfactory.py"), "."),
(os.path.join(BASE_FOLDER, "executable_utilities.py"), "."),
(os.path.join(BASE_FOLDER, "os_utilities.py"), "."),
(os.path.join(BASE_FOLDER, "web_utilities.py"), "."),
(os.path.join(BASE_FOLDER, "constants.py"), "."),
(os.path.join(BASE_FOLDER, "threads.py"), "."),
(os.path.join(BASE_FOLDER, "default_imgs_rc.py"), "."),
(os.path.join(BASE_FOLDER, "cacert.pem"), "."),
]
a = Analysis([os.path.join(BASE_FOLDER, 'updater.py')], # noqa: 821
pathex=[os.getcwd()],
binaries=[],
datas=data_file,
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, # noqa: 821
a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz, # noqa: 821
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='_ArtemisUpdater',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
runtime_tmpdir=None,
console=False,
icon='Artemis3.ico')

View File

@@ -6,34 +6,47 @@
## ARTEMIS 3 .SPEC FILES ## ARTEMIS 3 .SPEC FILES
Artemis 3 .spec files are used by the package **pyinstaller** (https://www.pyinstaller.org/) to build a single standalone executable. The extreme versatility of this package is the fact that every external dependency is already embedded into the bundle. The interpreter of Python 3 is also included. Artemis 3 .spec files are used by the package **pyinstaller** (https://www.pyinstaller.org/) to build a single standalone executable (or a one-dir package). Every external dependency is already embedded into the bundle. The interpreter of Python 3 is also included.
## Requirements ## Requirements
- Python 3.7.0+ - Python 3.7.0+
- Pyinstaller - Pyinstaller
## Package Building **IMPORTANT:** *To generate the standalone and the one-dir package, you must use an operating system that coincides with the target one (pyinstaller doesn't allow cross-compilation).*
**IMPORTANT:** *To generate the standalone, you must use an operating system that coincides with the target one (pyinstaller doesn't allow cross-compilation).*
1. Download a fresh copy of the git repository. **IMPORTANT (LINUX COMPILING):** *The executable that PyInstaller builds is not fully static, in that it still depends on the system libc. **Under Linux, the ABI of GLIBC is backward compatible, but not forward compatible. So if you link against a newer GLIBC, you can't run the resulting executable on an older system**. The supplied binary bootloader should work with older GLIBC. However, the libpython.so and other dynamic libraries still depend on the newer GLIBC. The solution is to compile the Python interpreter with its modules (and also probably bootloader) on the oldest system you have around so that it gets linked with the oldest version of GLIBC.* (Source: PyInstaller)
2. Choose the target OS in `spec_files` folder and copy the whole content into `src`
3. Open a terminal into `src` and run: **NOTE.** Depending on the number of packages installed in your python environemnt, the size of the executables file can vary significantly. In order to get the smallest size possible it is recommend to initialize a fresh virtual environment with just the requirements and pyinstaller.
## Package Building (standalone aka one-file, high portability, **suggested**)
1. Download/clone the git repository.
2. In the `spec_files/<your OS>` folder open a terminal and type
``` ```
pyinstaller Artemis.spec pyinstaller Artemis.spec
``` ```
4. The ready-to-use compiled software is now present into `src/dist` folder. 3. An Artemis executable should be produced in the `dist/` folder. The `build/` folder
can be deleted.
## License ## Package Building (one-dir, shorter startup time, low portability)
This program (ARTEMIS 3, 2014-2019) is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 1. Download/clone the git repository.
2. In the `spec_files/<your OS>` folder open a terminal and type
```
pyinstaller Artemis_onedir.spec
```
3. An Artemis executable should be produced in `dist/Artemis/`. The `build/` can
be deleted.
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 General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see: www.gnu.org/licenses You can save a copy of the executable in a folder of you choice. At startup it will ask you to download the database and also warn you that the `themes` folder is missing. To avoid this, copy `src/Data` and `src/themes` in the folder containing the executable.
## Thanks ## Build scripts
* **Marco Dalla Tiezza** - *Artemis I-II developer, DB parsing, Website* Provided you satisfy the requirements (see [requirements.txt](../requirements/requirements.txt)) and have pyinstaller installed, running a `build.*` script in `<your os>` folder will produce an `output` folder with:
* [**Alessandro Ceccato**](https://github.com/alessandro90 "GitHub profile") - *Artemis III lead developer*
* **Paolo Romani (IZ1MLL)** - *Lead β Tester, RF specialist* - Executable versions of Artemis and the updater;
* **Carl Colena** - *Sigidwiki admin, β Tester, Signals expert* - compressed versions of the same files;
* **Marco Bortoli** - *macOS deployment, β Tester* - a folder called `Artemis/` containing the executables and the `theme` folder
* **Pierpaolo Pravatto** - *Wiki page, β Tester* - a compressed version of the folder
* **Francesco Capostagno, Luca, Pietro** - *β Tester*
At the end of the process the script writes on standard output the size and sha256 code for the compressed files.
**NOTE.** For Windows you will need a 7z installation. Also check the path hardcoded in `/Windows/build.bat`.

View File

@@ -0,0 +1,50 @@
# -*- mode: python -*-
import glob
import os
block_cipher = None
SRC_PATH = "../../src/"
data_file = [
(f, '.') for f in glob.glob(SRC_PATH + '*.[pu][yi]')
if f.split('/')[-1] != "artemis.py"
]
data_file.append((SRC_PATH + 'cacert.pem', '.'))
a = Analysis(SRC_PATH + ['artemis.py'], # noqa: 821
pathex=[os.getcwd()],
binaries=[],
datas=data_file,
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, # noqa: 821
a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz, # noqa: 821
a.scripts,
[],
exclude_binaries=True,
name='Artemis',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
icon='Artemis3.ico')
coll = COLLECT(exe, # noqa: 821
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name='Artemis')

View File

@@ -1,12 +1,21 @@
# -*- mode: python -*- # -*- mode: python -*-
import glob
import os
block_cipher = None block_cipher = None
import glob,os
data_file = [(f, '.') for f in glob.glob('*.[pu][yi]') if f != "artemis.py"] SRC_PATH = "../../src/"
a = Analysis(['artemis.py'], data_file = [
(f, '.') for f in glob.glob(SRC_PATH + '*.[pu][yi]')
if f.split('/')[-1] != "artemis.py" and f.split('/')[-1] != "updater.py"
]
data_file.append((SRC_PATH + 'cacert.pem', '.'))
a = Analysis([SRC_PATH + 'artemis.py'], # noqa: 821
pathex=[os.getcwd()], pathex=[os.getcwd()],
binaries=[], binaries=[],
datas=data_file, datas=data_file,
@@ -18,9 +27,10 @@ a = Analysis(['artemis.py'],
win_private_assemblies=False, win_private_assemblies=False,
cipher=block_cipher, cipher=block_cipher,
noarchive=False) noarchive=False)
pyz = PYZ(a.pure, a.zipped_data, pyz = PYZ(a.pure, # noqa: 821
cipher=block_cipher) a.zipped_data,
exe = EXE(pyz, cipher=block_cipher)
exe = EXE(pyz, # noqa: 821
a.scripts, a.scripts,
a.binaries, a.binaries,
a.zipfiles, a.zipfiles,
@@ -32,4 +42,6 @@ exe = EXE(pyz,
strip=False, strip=False,
upx=True, upx=True,
runtime_tmpdir=None, runtime_tmpdir=None,
console=False , icon='Artemis3.ico') console=False,
icon='Artemis3.ico',
uac_admin=True)

View File

@@ -0,0 +1,30 @@
ECHO OFF
ECHO Building Artemis executable...
RMDIR /s /q output
MKDIR output
pyinstaller artemis.spec
ECHO Remove directories
MOVE dist\Artemis.exe .\output\Artemis.exe
RMDIR /s /q dist
RMDIR /s /q build
ECHO *************
ECHO *************
ECHO Building updater...
pyinstaller updater.spec
ECHO Remove directories
MOVE dist\_ArtemisUpdater.exe .\output\_ArtemisUpdater.exe
RMDIR /s /q dist
RMDIR /s /q build
CD output
MKDIR Artemis
XCOPY /y Artemis.exe Artemis\
XCOPY /e /k /y ..\..\..\src\themes Artemis\themes\ /EXCLUDE:..\excluded_files.txt
XCOPY /y _ArtemisUpdater.exe Artemis\
ECHO "Compress files themes+Artemis.exe -> Artemis.zip"
"C:\Program Files\7-Zip\7z.exe" a Artemis_win.zip Artemis.exe ..\..\..\src\themes
"C:\Program Files\7-Zip\7z.exe" a _ArtemisUpdater_win.zip _ArtemisUpdater.exe
ECHO "Compress all files for website download"
"C:\Program Files\7-Zip\7z.exe" a ArtemisWebsite_win.zip Artemis\
python ..\..\__get_hash_code.py Artemis_win.zip _ArtemisUpdater_win.zip ArtemisWebsite_win.zip
CD ..
ECHO Done.

View File

@@ -0,0 +1 @@
__current_theme

View File

@@ -0,0 +1,52 @@
# -*- mode: python ; coding: utf-8 -*-
import os
BASE_FOLDER = "../../src/"
block_cipher = None
data_file = [
(os.path.join(BASE_FOLDER, "download_db_window.ui"), "."),
(os.path.join(BASE_FOLDER, "download_window.py"), "."),
(os.path.join(BASE_FOLDER, "utilities.py"), "."),
(os.path.join(BASE_FOLDER, "versioncontroller.py"), "."),
(os.path.join(BASE_FOLDER, "downloadtargetfactory.py"), "."),
(os.path.join(BASE_FOLDER, "executable_utilities.py"), "."),
(os.path.join(BASE_FOLDER, "os_utilities.py"), "."),
(os.path.join(BASE_FOLDER, "web_utilities.py"), "."),
(os.path.join(BASE_FOLDER, "constants.py"), "."),
(os.path.join(BASE_FOLDER, "threads.py"), "."),
(os.path.join(BASE_FOLDER, "default_imgs_rc.py"), "."),
(os.path.join(BASE_FOLDER, "cacert.pem"), "."),
]
a = Analysis([os.path.join(BASE_FOLDER, 'updater.py')], # noqa: 821
pathex=[os.getcwd()],
binaries=[],
datas=data_file,
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, # noqa: 821
a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz, # noqa: 821
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='_ArtemisUpdater',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
runtime_tmpdir=None,
console=False,
icon='Artemis3.ico',
uac_admin=True)

View File

@@ -0,0 +1,31 @@
import hashlib
import sys
"""Print on stadard output the size in KB and sha256 codes of a list
of command line-provided file names."""
print()
try:
fnames = sys.argv[1:]
except Exception:
print("Provide a valid filename.")
exit()
for fname in fnames:
try:
with open(fname, mode='rb') as f:
target = f.read()
except Exception:
print("File not found")
exit()
code = hashlib.sha256()
code.update(target)
hash_code = code.hexdigest()
print("File name:", fname)
print("Size (KB):", round(len(target) / 1024, 3))
print("Hash code:", hash_code)
print("-" * 80)

View File

@@ -1,12 +1,21 @@
# -*- mode: python -*- # -*- mode: python -*-
import glob
import os
block_cipher = None block_cipher = None
import glob, os
data_file = [(f, '.') for f in glob.glob('*.[pu][yi]') if f != "artemis.py"]
data_file.append(('themes','./themes'))
a = Analysis(['artemis.py'], SRC_PATH = "../../src/"
data_file = [
(f, '.') for f in glob.glob(SRC_PATH + '*.[pu][yi]')
if f.split('/')[-1] != "artemis.py"
]
data_file.extend(((SRC_PATH + 'cacert.pem', '.'), (SRC_PATH + 'themes', './themes')))
a = Analysis([SRC_PATH + 'artemis.py'], # noqa: 821
pathex=[os.getcwd()], pathex=[os.getcwd()],
binaries=[], binaries=[],
datas=data_file, datas=data_file,
@@ -18,9 +27,10 @@ a = Analysis(['artemis.py'],
win_private_assemblies=False, win_private_assemblies=False,
cipher=block_cipher, cipher=block_cipher,
noarchive=False) noarchive=False)
pyz = PYZ(a.pure, a.zipped_data, pyz = PYZ(a.pure, # noqa: 821
cipher=block_cipher) a.zipped_data,
exe = EXE(pyz, cipher=block_cipher)
exe = EXE(pyz, # noqa: 821
a.scripts, a.scripts,
[], [],
exclude_binaries=True, exclude_binaries=True,
@@ -29,15 +39,15 @@ exe = EXE(pyz,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=True, upx=True,
console=False ) console=False)
coll = COLLECT(exe, coll = COLLECT(exe, # noqa: 821
a.binaries, a.binaries,
a.zipfiles, a.zipfiles,
a.datas, a.datas,
strip=False, strip=False,
upx=True, upx=True,
name='Artemis') name='Artemis')
app = BUNDLE(coll, app = BUNDLE(coll, # noqa: 821
name='Artemis.app', name='Artemis.app',
icon='Artemis3.icns', icon='Artemis3.icns',
bundle_identifier=None) bundle_identifier=None)

56
src/acfvalue.py Normal file
View File

@@ -0,0 +1,56 @@
from constants import Constants
class ACFValue:
"""Handle complex/multiple ACF values."""
def __init__(self, value):
"""Given a string describing an acf value, build an object with the
following attrributes:
- is_numeric: whether the value is a number or a string;
- numeric_value: the numeric value (if any, zero otherwise);
- unknown: whether the value is unknown."""
if value == Constants.UNKNOWN:
self._value = value
self._description = ""
self._string = self._value
self.is_numeric = False
self.unknown = True
self.numeric_value = 0.0
else:
self.unknown = False
if Constants.ACF_SEPARATOR in value:
description, acf_value = value.split(Constants.ACF_SEPARATOR)
self._description = description
self._value = acf_value
self._string = f"{self._description}: {self._value}"
else:
self._description = ""
self._value = value
self._string = self._value
try:
self.numeric_value = float(self._value)
except Exception:
self.is_numeric = False
self.numeric_value = 0.0
else:
self.is_numeric = True
self._string += " ms"
@classmethod
def list_from_series(cls, series):
"""Parse all acf values from the database.
Accept an iterable of ACFValues.
Return a list of lists of ACFValues."""
entries = []
for entry in series:
entries.append([
cls(value.rstrip('ms').strip()) for value in entry.split(Constants.FIELD_SEPARATOR)
])
return entries
@staticmethod
def concat_strings(acf_list_values):
"""Concatenate a list of ACFValues to be displayed."""
return '\n'.join(s._string for s in acf_list_values)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,15 @@ import os
from pygame import mixer from pygame import mixer
from PyQt5.QtCore import QTimer, pyqtSlot, QObject from PyQt5.QtCore import QTimer, pyqtSlot, QObject
from constants import Constants
import qtawesome as qta import qtawesome as qta
from constants import Constants
class AudioPlayer(QObject): class AudioPlayer(QObject):
"""Subclass QObject. Audio player widget for the audio samples. """Subclass QObject. Audio player widget for the audio samples.
The only public methods are the __init__ The only public methods are the __init__
method, set_audio_player, which loads the current file and refresh_btns_colors. method, set_audio_player, which loads the current file and refresh.
Everything else is managed internally.""" Everything else is managed internally."""
_TIME_STEP = 500 # Milliseconds. _TIME_STEP = 500 # Milliseconds.
@@ -19,17 +19,21 @@ class AudioPlayer(QObject):
pause, pause,
stop, stop,
volume, volume,
loop,
audio_progress, audio_progress,
active_color, active_color,
inactive_color): inactive_color):
"""Initialize the player.""" """Initialize the player."""
super().__init__() super().__init__()
self._active_color = active_color
self._inactive_color = inactive_color
self._paused = False self._paused = False
self._first_call = True self._first_call = True
self._play = play self._play = play
self._pause = pause self._pause = pause
self._stop = stop self._stop = stop
self._volume = volume self._volume = volume
self._loop = loop
self._audio_progress = audio_progress self._audio_progress = audio_progress
self._audio_file = None self._audio_file = None
self._timer = QTimer() self._timer = QTimer()
@@ -38,22 +42,45 @@ class AudioPlayer(QObject):
self._pause.clicked.connect(self._pause_audio) self._pause.clicked.connect(self._pause_audio)
self._stop.clicked.connect(self._stop_audio) self._stop.clicked.connect(self._stop_audio)
self._volume.valueChanged.connect(self._set_volume) self._volume.valueChanged.connect(self._set_volume)
self._loop.clicked.connect(self._set_loop_icon)
self._play.setIconSize(self._play.size()) self._play.setIconSize(self._play.size())
self._pause.setIconSize(self._pause.size()) self._pause.setIconSize(self._pause.size())
self._stop.setIconSize(self._stop.size()) self._stop.setIconSize(self._stop.size())
self.refresh_btns_colors(active_color, inactive_color) self._loop.setIconSize(self._loop.size())
self.refresh(active_color, inactive_color)
def refresh_btns_colors(self, active_color, inactive_color): @pyqtSlot()
def _set_loop_icon(self):
"""Set the icon for the loop audio button."""
if self._loop.isChecked():
loop_icon = qta.icon(
'fa5s.redo-alt',
color=self._active_color,
color_disabled=self._inactive_color,
animation=qta.Spin(self._loop)
)
else:
loop_icon = qta.icon(
'fa5s.redo-alt',
color=self._active_color,
color_disabled=self._inactive_color
)
self._loop.setIcon(loop_icon)
def refresh(self, active_color, inactive_color):
"""Repaint the buttons of the widgetd after the theme has changed.""" """Repaint the buttons of the widgetd after the theme has changed."""
self._play.setIcon(qta.icon('fa5.play-circle', self._active_color = active_color
self._inactive_color = inactive_color
self._play.setIcon(qta.icon('fa5s.play',
color=active_color, color=active_color,
color_disabled=inactive_color)) color_disabled=inactive_color))
self._pause.setIcon(qta.icon('fa5.pause-circle', self._pause.setIcon(qta.icon('fa5s.pause',
color=active_color, color=active_color,
color_disabled=inactive_color)) color_disabled=inactive_color))
self._stop.setIcon(qta.icon('fa5.stop-circle', self._stop.setIcon(qta.icon('fa5s.stop',
color=active_color, color=active_color,
color_disabled=inactive_color)) color_disabled=inactive_color))
self._set_loop_icon()
@pyqtSlot() @pyqtSlot()
def _set_volume(self): def _set_volume(self):
@@ -65,6 +92,8 @@ class AudioPlayer(QObject):
def _reset_audio_widget(self): def _reset_audio_widget(self):
"""Reset the widget. Stop all playing samples.""" """Reset the widget. Stop all playing samples."""
self._first_call = True
self._paused = False
if mixer.get_init(): if mixer.get_init():
if mixer.music.get_busy(): if mixer.music.get_busy():
mixer.music.stop() mixer.music.stop()
@@ -72,7 +101,6 @@ class AudioPlayer(QObject):
mixer.quit() mixer.quit()
self._audio_progress.reset() self._audio_progress.reset()
self._enable_buttons(False, False, False) self._enable_buttons(False, False, False)
self._paused = False
@pyqtSlot() @pyqtSlot()
def _update_bar(self): def _update_bar(self):
@@ -81,7 +109,11 @@ class AudioPlayer(QObject):
if pos == -1: if pos == -1:
self._timer.stop() self._timer.stop()
self._audio_progress.reset() self._audio_progress.reset()
self._enable_buttons(True, False, False) if self._loop.isChecked():
self._play_audio()
self._enable_buttons(False, True, True)
else:
self._enable_buttons(True, False, False)
else: else:
self._audio_progress.setValue(pos) self._audio_progress.setValue(pos)
@@ -93,10 +125,8 @@ class AudioPlayer(QObject):
def set_audio_player(self, fname=""): def set_audio_player(self, fname=""):
"""Set the current audio sample.""" """Set the current audio sample."""
self._first_call = True
self._reset_audio_widget() self._reset_audio_widget()
full_name = os.path.join( full_name = os.path.join(
Constants.DATA_FOLDER,
Constants.AUDIO_FOLDER, Constants.AUDIO_FOLDER,
fname + '.ogg' fname + '.ogg'
) )

4619
src/cacert.pem Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,21 @@
from collections import namedtuple from collections import namedtuple
from enum import Enum, auto from enum import Enum, auto
import os.path import os.path
from executable_utilities import get_executable_path
__BASE_FOLDER__ = get_executable_path()
class SupportedOs:
"""Supported operating systems."""
WINDOWS = "windows"
LINUX = "linux"
MAC = "mac"
class Ftype: class Ftype:
"""Container class to differentiate between frequency and band. """Container class to differentiate between frequency and band."""
Used in reset_fb_filters.
"""
FREQ = "freq" FREQ = "freq"
BAND = "band" BAND = "band"
@@ -20,30 +28,13 @@ class GfdType(Enum):
LOC = auto() LOC = auto()
class ChecksumWhat(Enum): class DownloadTarget(Enum):
"""Enum class to distinguish the object you want to verify the checksum.""" """Enum class to distinguish the object being downloaded."""
FOLDER = auto() DATA_FOLDER = auto()
DB = auto() DB = auto()
SOFTWARE = auto()
UPDATER = auto()
class Messages:
"""Container class for messages to be displayed."""
DB_UP_TO_DATE = "Already up to date"
DB_UP_TO_DATE_MSG = "No newer version to download."
DB_NEW_VER = "New version available"
DB_NEW_VER_MSG = "A new version of the database is available for download."
NO_DB_AVAIL = "No database detected."
NO_DB = "No database"
DOWNLOAD_NOW_QUESTION = "Do you want to download it now?"
DOWNLOAD_ANYWAY_QUESTION = "Do you want to download it anyway?"
NO_CONNECTION = "No connection"
NO_CONNECTION_MSG = "Unable to establish an internet connection."
BAD_DOWNLOAD = "Something went wrong"
BAD_DOWNLOAD_MSG = "Something went wrong with the downaload.\nCheck your internet connection and try again."
SLOW_CONN = "Slow internet connection"
SLOW_CONN_MSG = "Your internet connection is unstable or too slow."
class Signal: class Signal:
@@ -88,7 +79,8 @@ class Database:
Signal.MODE, Signal.MODE,
Signal.INF_BAND, Signal.INF_BAND,
Signal.SUP_BAND, Signal.SUP_BAND,
Signal.CATEGORY_CODE) Signal.CATEGORY_CODE,
Signal.ACF,)
class ForecastColors: class ForecastColors:
@@ -108,11 +100,15 @@ _Band = namedtuple("Band", ["lower", "upper"])
class Constants: class Constants:
"""Container class for several constants of the software.""" """Container class for several constants of the software."""
EXECUTABLE_NAME = os.path.join(__BASE_FOLDER__, "Artemis")
UPDATER_SOFTWARE = os.path.join(__BASE_FOLDER__, "_ArtemisUpdater")
CLICK_TO_UPDATE_STR = "Click to update" CLICK_TO_UPDATE_STR = "Click to update"
VERSION_LINK = "https://aresvalley.com/Storage/Artemis/Package/latest_versions.json"
SIGIDWIKI = "https://www.sigidwiki.com/wiki/Signal_Identification_Guide" SIGIDWIKI = "https://www.sigidwiki.com/wiki/Signal_Identification_Guide"
ADD_SIGNAL_LINK = "https://www.sigidwiki.com/index.php/Special:FormEdit/Signal/?preload=Signal_Identification_Wiki:Signal_form_preload_text" ADD_SIGNAL_LINK = "https://www.sigidwiki.com/index.php/Special:FormEdit/Signal/?preload=Signal_Identification_Wiki:Signal_form_preload_text"
FORUM_LINK = "https://aresvalley.com/community/" FORUM_LINK = "https://aresvalley.com/community/"
ARESVALLEY_LINK = "https://aresvalley.com/" ARESVALLEY_LINK = "https://aresvalley.com/"
GITHUB_REPO = "https://github.com/AresValley/Artemis"
RTL_SDL_LINK = "https://www.rtl-sdr.com/" RTL_SDL_LINK = "https://www.rtl-sdr.com/"
UPDATING_STR = "Updating..." UPDATING_STR = "Updating..."
ACF_DOCS = "https://aresvalley.com/documentation/" ACF_DOCS = "https://aresvalley.com/documentation/"
@@ -123,21 +119,18 @@ class Constants:
SPACE_WEATHER_SGAS = "https://services.swpc.noaa.gov/text/sgas.txt" SPACE_WEATHER_SGAS = "https://services.swpc.noaa.gov/text/sgas.txt"
SPACE_WEATHER_GEO_STORM = "https://services.swpc.noaa.gov/text/3-day-forecast.txt" SPACE_WEATHER_GEO_STORM = "https://services.swpc.noaa.gov/text/3-day-forecast.txt"
SPACE_WEATHER_INFO = "https://www.swpc.noaa.gov/sites/default/files/images/NOAAscales.pdf" SPACE_WEATHER_INFO = "https://www.swpc.noaa.gov/sites/default/files/images/NOAAscales.pdf"
SPACE_WEATHER_IMGS = ["http://www.mmmonvhf.de/eme/eme.png", SPACE_WEATHER_IMGS = ["https://www.mmmonvhf.de/eme/eme.png",
"http://www.mmmonvhf.de/ms/ms.png", "https://www.mmmonvhf.de/ms/ms.png",
"http://www.mmmonvhf.de/es/es.png", "https://www.mmmonvhf.de/es/es.png",
"http://www.mmmonvhf.de/solar/solar.png", "https://www.mmmonvhf.de/solar/solar.png",
"http://amunters.home.xs4all.nl/eskip50status.gif", "https://amunters.home.xs4all.nl/eskip50status.gif",
"http://amunters.home.xs4all.nl/eskip70status.gif", "https://amunters.home.xs4all.nl/eskip70status.gif",
"http://amunters.home.xs4all.nl/eskipstatus.gif", "https://amunters.home.xs4all.nl/eskipstatus.gif",
"https://amunters.home.xs4all.nl/eskipstatusNA.gif", "https://amunters.home.xs4all.nl/eskipstatusNA.gif",
"https://amunters.home.xs4all.nl/aurorastatus.gif"] "https://amunters.home.xs4all.nl/aurorastatus.gif"]
SEARCH_LABEL_IMG = "search_icon.png" SEARCH_LABEL_IMG = "search_icon.png"
VOLUME_LABEL_IMG = "volume.png" VOLUME_LABEL_IMG = "volume.png"
DATA_FOLDER = "Data"
SPECTRA_FOLDER = "Spectra"
SPECTRA_EXT = ".png" SPECTRA_EXT = ".png"
AUDIO_FOLDER = "Audio"
ACTIVE = "active" ACTIVE = "active"
INACTIVE = "inactive" INACTIVE = "inactive"
LABEL_ON_COLOR = "on" LABEL_ON_COLOR = "on"
@@ -181,6 +174,62 @@ class Constants:
NOT_AVAILABLE = "spectrumnotavailable.png" NOT_AVAILABLE = "spectrumnotavailable.png"
NOT_SELECTED = "nosignalselected.png" NOT_SELECTED = "nosignalselected.png"
FIELD_SEPARATOR = ";" FIELD_SEPARATOR = ";"
ACF_SEPARATOR = " - "
DATA_FOLDER = os.path.join(__BASE_FOLDER__, "Data")
SPECTRA_FOLDER = os.path.join(DATA_FOLDER, "Spectra")
AUDIO_FOLDER = os.path.join(DATA_FOLDER, "Audio")
DEFAULT_IMGS_FOLDER = os.path.join(":", "pics", "default_pics") DEFAULT_IMGS_FOLDER = os.path.join(":", "pics", "default_pics")
DEFAULT_NOT_SELECTED = os.path.join(DEFAULT_IMGS_FOLDER, NOT_SELECTED) DEFAULT_NOT_SELECTED = os.path.join(DEFAULT_IMGS_FOLDER, NOT_SELECTED)
DEFAULT_NOT_AVAILABLE = os.path.join(DEFAULT_IMGS_FOLDER, NOT_AVAILABLE) DEFAULT_NOT_AVAILABLE = os.path.join(DEFAULT_IMGS_FOLDER, NOT_AVAILABLE)
class Messages:
"""Container class for messages to be displayed."""
FEATURE_NOT_AVAILABLE = "Feature not available"
SCRIPT_NOT_UPDATE = "When running from source, software updates\ncannot be checked."
UPDATES_AVAILABALE = "Updates available"
UPDATES_MSG = "Do you want to install the updates now?"
UP_TO_DATE = "Already up to date"
UP_TO_DATE_MSG = "No newer version to download."
DB_NEW_VER = "New version available"
DB_NEW_VER_MSG = "A new version of the database is available for download."
NO_DB_AVAIL = "No database detected."
NO_DB = "No database"
DOWNLOAD_NOW_QUESTION = "Do you want to download it now?"
DOWNLOAD_ANYWAY_QUESTION = "Do you want to download it anyway?"
NO_CONNECTION = "No connection"
NO_CONNECTION_MSG = "Unable to establish an internet connection."
BAD_DOWNLOAD = "Something went wrong"
BAD_DOWNLOAD_MSG = "Something went wrong with the download.\nCheck your internet connection and try again."
SLOW_CONN = "Slow internet connection"
SLOW_CONN_MSG = "Your internet connection is unstable or too slow."
NEW_VERSION_AVAILABLE = "New software version"
NEW_VERSION_MSG = lambda v: f"The software version {v} is available." # noqa: E731
DOWNLOAD_SUGG_MSG = "Download new version now?"
class ThemeConstants:
"""Container class for all the theme-related constants."""
EXTENSION = ".qss"
ICONS_FOLDER = "icons"
DEFAULT = "dark"
CURRENT = "__current_theme"
COLORS = "colors.txt"
COLOR_SEPARATOR = "="
DEFAULT_ACTIVE_COLOR = "#000000"
DEFAULT_INACTIVE_COLOR = "#9f9f9f"
DEFAULT_OFF_COLORS = "#000000", "#434343"
DEFAULT_ON_COLORS = "#4b79a1", "#283e51"
DEFAULT_TEXT_COLOR = "#ffffff"
THEME_NOT_FOUND = "Theme not found"
MISSING_THEME = "Missing theme folder."
MISSING_THEME_FOLDER = "Themes folder not found.\nOnly the basic theme is available."
THEME_FOLDER_NOT_FOUND = "Themes folder not found"
FOLDER = os.path.join(__BASE_FOLDER__, "themes")
DEFAULT_ICONS_PATH = os.path.join(FOLDER, DEFAULT, ICONS_FOLDER)
DEFAULT_SEARCH_LABEL_PATH = os.path.join(DEFAULT_ICONS_PATH, Constants.SEARCH_LABEL_IMG)
DEFAULT_VOLUME_LABEL_PATH = os.path.join(DEFAULT_ICONS_PATH, Constants.VOLUME_LABEL_IMG)
CURRENT_THEME_FILE = os.path.join(FOLDER, CURRENT)
DEFAULT_THEME_PATH = os.path.join(FOLDER, DEFAULT)

View File

@@ -7,11 +7,11 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>400</width> <width>400</width>
<height>160</height> <height>185</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
<string>Download database</string> <string>Downloading</string>
</property> </property>
<property name="windowIcon"> <property name="windowIcon">
<iconset resource="default_imgs.qrc"> <iconset resource="default_imgs.qrc">
@@ -29,7 +29,7 @@
</font> </font>
</property> </property>
<property name="text"> <property name="text">
<string>Downloading database <string>Downloading updates
Please wait... Please wait...
</string> </string>
</property> </property>
@@ -69,7 +69,7 @@ Please wait...
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QProgressBar" name="progressBar"> <widget class="QProgressBar" name="_progress_bar">
<property name="minimum"> <property name="minimum">
<number>0</number> <number>0</number>
</property> </property>

View File

@@ -2,7 +2,8 @@ from PyQt5 import uic
from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal
from PyQt5.QtWidgets import QWidget from PyQt5.QtWidgets import QWidget
from threads import DownloadThread, ThreadStatus from threads import DownloadThread, ThreadStatus
from utilities import pop_up, resource_path from utilities import pop_up
from executable_utilities import resource_path
from constants import Constants, Messages from constants import Constants, Messages
@@ -12,10 +13,12 @@ Ui_Download_window, _ = uic.loadUiType(
class DownloadWindow(QWidget, Ui_Download_window): class DownloadWindow(QWidget, Ui_Download_window):
"""Subclass QWidget and Ui_Download_window. It is the window displayed during the database download.""" """Subclass QWidget and Ui_Download_window. It is the window displayed during
downloads and software updates."""
complete = pyqtSignal() complete = pyqtSignal()
closed = pyqtSignal() closed = pyqtSignal()
_PROGRESS_CONEVERSION_FACTOR = 1024
def __init__(self): def __init__(self):
"""Initialize the window.""" """Initialize the window."""
@@ -47,10 +50,22 @@ class DownloadWindow(QWidget, Ui_Download_window):
self._download_thread.speed_progress.connect(self._display_speed) self._download_thread.speed_progress.connect(self._display_speed)
self.closed.connect(self._download_thread.set_exit) self.closed.connect(self._download_thread.set_exit)
self.cancel_btn.clicked.connect(self._terminate_process) self.cancel_btn.clicked.connect(self._terminate_process)
self._size = 0
self.target = None
def start_download(self): def _prepare_progress_bar(self, size):
"""Prepare the progress bar for the upcoming download."""
self._progress_bar.setMinimum(0)
self._progress_bar.setMaximum(size)
self._progress_bar.setValue(0)
def activate(self, target):
"""Start the download thread.""" """Start the download thread."""
self._download_thread.start() self._size = target.size
self.target = target.target
self._prepare_progress_bar(target.size)
self._download_thread.start(target)
self.show()
def _download_format_str(self, n): def _download_format_str(self, n):
"""Return a well-formatted string with the downloaded MB.""" """Return a well-formatted string with the downloaded MB."""
@@ -77,6 +92,8 @@ class DownloadWindow(QWidget, Ui_Download_window):
self.status_lbl.setText(self._download_format_str(progress)) self.status_lbl.setText(self._download_format_str(progress))
elif progress == Constants.EXTRACTING_CODE: elif progress == Constants.EXTRACTING_CODE:
self.status_lbl.setText(Constants.EXTRACTING_MSG) self.status_lbl.setText(Constants.EXTRACTING_MSG)
if self._size > 0:
self._progress_bar.setValue(progress * self._PROGRESS_CONEVERSION_FACTOR)
def show(self): def show(self):
"""Extends QWidget.show. Set downloaded MB and speed to zero.""" """Extends QWidget.show. Set downloaded MB and speed to zero."""

View File

@@ -0,0 +1,164 @@
from contextlib import contextmanager
from shutil import rmtree
from os import remove
import os.path
import stat
from constants import (
Constants,
Database,
__BASE_FOLDER__,
ThemeConstants,
DownloadTarget,
SupportedOs,
)
from os_utilities import get_os
from web_utilities import get_folder_hash_code
from zipfile import ZipFile
from tarfile import TarFile
class _ZipExtractor:
"""Extractor class for zip files.
Exposes a static method which can be used as a context manager."""
@staticmethod
@contextmanager
def open(fileobj):
zipped = ZipFile(fileobj)
try:
yield zipped
finally:
zipped.close()
class _TarExtractor:
"""Extractor class for tar files.
Exposes a static method which can be used as a context manager."""
@staticmethod
@contextmanager
def open(fileobj):
tarfile = TarFile.open(fileobj=fileobj)
try:
yield tarfile
finally:
tarfile.close()
EXTRACTORS = {
SupportedOs.WINDOWS: _ZipExtractor,
SupportedOs.LINUX: _TarExtractor,
# No extractor for MacOs, just download the file through the browser.
}
def _on_rmtree_error(func, path, excinfo):
"""Function called whenever rmtree fails."""
os.chmod(path, stat.S_IWRITE)
func(path)
def _delete_data_folder():
"""Delete the Data folder."""
if os.path.exists(Constants.DATA_FOLDER):
rmtree(Constants.DATA_FOLDER, onerror=_on_rmtree_error)
def _delete_updater():
"""Delete the updater program."""
if os.path.exists(Constants.UPDATER_SOFTWARE):
remove(Constants.UPDATER_SOFTWARE)
def _delete_software():
"""Delete the main program and the themes folder."""
if os.path.exists(Constants.EXECUTABLE_NAME):
remove(Constants.EXECUTABLE_NAME) # Remove Artemis executable.
if os.path.exists(ThemeConstants.FOLDER): # One could not have the theme folder for some reason.
rmtree(ThemeConstants.FOLDER, onerror=_on_rmtree_error)
class _DataFolderInfo:
"""Simple class to implement the interface of a 'target' object for the data folder:
- url;
- hash_code;
- size."""
def __init__(self):
self.url = Database.LINK_LOC
self.hash_code = get_folder_hash_code()
self.size = 0
class _BaseDownloadTarget:
"""Base class for the '_Download*Target' objects.
Contains all the attributes needed by DownloadWindow and DownloadThread
to do the job."""
def __init__(self, target, dest_path, target_enum, Extractor, delete_files):
self.url = target.url
self.hash_code = target.hash_code
self.size = target.size
self.dest_path = dest_path
self.target = target_enum
self.Extractor = Extractor
self.delete_files = delete_files
class _DownloadDataFolderTarget(_BaseDownloadTarget):
"""Extend _BaseDownloadTarget. Represent the data folder."""
def __init__(self, data_folder_info, dest_path=__BASE_FOLDER__):
super().__init__(
target=data_folder_info,
dest_path=dest_path,
target_enum=DownloadTarget.DATA_FOLDER,
Extractor=_ZipExtractor,
delete_files=_delete_data_folder
)
class _DownloadSoftwareTarget(_BaseDownloadTarget):
"""Extends _BaseDownloadTarget. Represents the main software."""
def __init__(self, software, dest_path=__BASE_FOLDER__):
super().__init__(
target=software,
dest_path=dest_path,
target_enum=DownloadTarget.SOFTWARE,
Extractor=EXTRACTORS[get_os()],
delete_files=_delete_software
)
class _DownloadUpdaterTarget(_BaseDownloadTarget):
"""Extends _BaseDownloadTarget. Represents the updater software."""
def __init__(self, updater, dest_path=__BASE_FOLDER__):
super().__init__(
target=updater,
dest_path=dest_path,
target_enum=DownloadTarget.UPDATER,
Extractor=EXTRACTORS[get_os()],
delete_files=_delete_updater
)
def get_download_target(target_type, target=None):
"""Return a Download*Obj based on the target download.
These objects expose a common interface:
Attributes:
- url;
- hash_code;
- dest_path;
- target: an element of the enum DownloadTarget;
- Extractor: an object which exposes an 'open(fileobj)' method
to extract compressed files;
- delete_files: a function to remove the old files."""
if target_type is DownloadTarget.DATA_FOLDER:
return _DownloadDataFolderTarget(_DataFolderInfo())
elif target_type is DownloadTarget.UPDATER and target is not None:
return _DownloadUpdaterTarget(target)
elif target_type is DownloadTarget.SOFTWARE and target is not None:
return _DownloadSoftwareTarget(target)
else:
raise Exception("ERROR: Invalid download target!")

View File

@@ -0,0 +1,33 @@
import sys
from shutil import which
import os
import os.path
def _is_executable_version():
"""Return whether the binary version is running."""
return hasattr(sys, "_MEIPASS")
IS_BINARY = _is_executable_version()
def get_executable_path():
"""Check whether the executable is in the PATH folder.
Return the full path or just an ampty string if it is not found
in the PATH folder."""
path = which("Artemis")
if path is not None:
return os.path.dirname(path)
else: # Assume that the executable is in the cwd.
return os.curdir
def resource_path(relative_path):
"""Get absolute path to resource, works for dev and for PyInstaller."""
try:
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)

847
src/filters.py Normal file
View File

@@ -0,0 +1,847 @@
"""This module contains all the filter-related classes and functions.
The only class exposed is Filters which provides the following methods:
- ok(signal_name): to check if all the filters are passed;
- reset(): to reset all the applied filters;
- refresh(): used when the theme is changed."""
from functools import partial
import webbrowser
from PyQt5.QtWidgets import QListWidgetItem, QTreeWidgetItem
from PyQt5.QtCore import pyqtSlot, QObject
from constants import (
Constants,
Ftype,
Signal,
)
from utilities import (
uncheck_and_emit,
connect_events_to_func,
filters_limit,
is_undef_freq,
is_undef_band,
safe_cast,
show_matching_strings,
get_field_entries,
)
class _BaseFilter(QObject):
"""Base class for all filters."""
def __init__(self, owner):
"""Positional argument:
owner - the object containing the filter screen."""
super().__init__()
self._owner = owner
def refresh(self):
"""Refresh the screen."""
pass
class _FreqBandMixIn:
"""Mixin class for the frequency and band filters.
Provides some functions used in both classes."""
@pyqtSlot()
def _set_min_value_upper_limit(self, lower_combo_box,
lower_spin_box,
upper_combo_box,
upper_spin_box):
"""Forbid to a lower limit to be greater than the corresponding upper one.
Used for frequency and bandwidth screens."""
if lower_spin_box.isEnabled():
unit_conversion = {
'Hz': ['kHz', 'MHz', 'GHz'],
'kHz': ['MHz', 'GHz'],
'MHz': ['GHz']
}
lower_units = lower_combo_box.currentText()
upper_units = upper_combo_box.currentText()
lower_value = lower_spin_box.value()
inf_limit = (lower_value * Constants.CONVERSION_FACTORS[lower_units]) \
// Constants.CONVERSION_FACTORS[upper_units]
counter = 0
while inf_limit > upper_spin_box.maximum():
counter += 1
inf_limit //= 1000
if upper_spin_box.minimum() != inf_limit:
upper_spin_box.setMinimum(inf_limit)
if counter > 0:
new_unit = unit_conversion[upper_units][counter - 1]
upper_combo_box.disconnect()
upper_combo_box.setCurrentText(new_unit)
upper_combo_box.currentTextChanged.connect(
partial(
self._set_min_value_upper_limit,
lower_combo_box,
lower_spin_box,
upper_combo_box,
upper_spin_box
)
)
@pyqtSlot()
def _reset_fb_filters(self, ftype):
"""Reset the Frequency or Bandwidth depending on 'ftype'.
ftype can be either Ftype.FREQ or Ftype.BAND.
"""
if ftype != Ftype.FREQ and ftype != Ftype.BAND:
raise ValueError("Wrong ftype in function '_reset_fb_filters'")
apply_remove_btn = getattr(self._owner, 'apply_remove_' + ftype + '_filter_btn')
include_undef_btn = getattr(self._owner, 'include_undef_' + ftype + 's')
activate_low = getattr(self._owner, 'activate_low_' + ftype + '_filter_btn')
activate_up = getattr(self._owner, 'activate_up_' + ftype + '_filter_btn')
lower_unit = getattr(self._owner, 'lower_' + ftype + '_filter_unit')
upper_unit = getattr(self._owner, 'upper_' + ftype + '_filter_unit')
lower_spinbox = getattr(self._owner, 'lower_' + ftype + '_spinbox')
upper_spinbox = getattr(self._owner, 'upper_' + ftype + '_spinbox')
lower_confidence = getattr(self._owner, 'lower_' + ftype + '_confidence')
upper_confidence = getattr(self._owner, 'lower_' + ftype + '_confidence')
default_val = 1 if ftype == Ftype.FREQ else 5000
if ftype == Ftype.FREQ:
for f in self._frequency_filters_btns:
if f.isChecked():
f.setChecked(False)
uncheck_and_emit(apply_remove_btn)
if include_undef_btn.isChecked():
include_undef_btn.setChecked(False)
uncheck_and_emit(activate_low)
uncheck_and_emit(activate_up)
lower_unit.setCurrentText("MHz")
upper_unit.setCurrentText("MHz")
lower_spinbox.setValue(default_val)
upper_spinbox.setMinimum(1)
upper_spinbox.setValue(default_val)
lower_confidence.setValue(0)
upper_confidence.setValue(0)
@pyqtSlot()
def _set_band_filter_label(self,
activate_low_btn,
lower_spinbox,
lower_unit,
lower_confidence,
activate_up_btn,
upper_spinbox,
upper_unit,
upper_confidence,
range_lbl):
"""Display the actual range applied for the signal's property search.
Used for frequency and bandwidth screens."""
activate_low = False
activate_high = False
color = self._owner.inactive_color
title = ''
to_display = ''
if activate_low_btn.isChecked():
activate_low = True
color = self._owner.active_color
min_value = lower_spinbox.value()
if lower_confidence.value() != 0:
min_value -= lower_spinbox.value() * lower_confidence.value() / 100
to_display += str(round(min_value, Constants.MAX_DIGITS)) \
+ ' ' + lower_unit.currentText()
else:
to_display += 'DC'
to_display += Constants.RANGE_SEPARATOR
if activate_up_btn.isChecked():
max_value = upper_spinbox.value()
activate_high = True
color = self._owner.active_color
if upper_confidence.value() != 0:
max_value += upper_spinbox.value() * upper_confidence.value() / 100
to_display += str(round(max_value, Constants.MAX_DIGITS)) + ' ' \
+ upper_unit.currentText()
else:
to_display += 'INF'
if activate_low and activate_high:
title = 'Band-pass\n\n'
elif activate_low and not activate_high:
title = 'Low-pass\n\n'
elif not activate_low and activate_high:
title = 'High-pass\n\n'
else:
title = "Selected range:\n\n"
to_display = "Inactive"
to_display = title + to_display
range_lbl.setText(to_display)
range_lbl.setStyleSheet(f'color: {color};')
class FreqFilter(_BaseFilter, _FreqBandMixIn):
"""Frequency filter class."""
def __init__(self, owner):
super().__init__(owner)
self.apply_remove_btn = self._owner.apply_remove_freq_filter_btn
self.reset_btn = self._owner.reset_frequency_filters_btn
self._frequency_filters_btns = (
self._owner.elf_filter_btn,
self._owner.slf_filter_btn,
self._owner.ulf_filter_btn,
self._owner.vlf_filter_btn,
self._owner.lf_filter_btn,
self._owner.mf_filter_btn,
self._owner.hf_filter_btn,
self._owner.vhf_filter_btn,
self._owner.uhf_filter_btn,
self._owner.shf_filter_btn,
self._owner.ehf_filter_btn,
)
self.apply_remove_btn.set_texts(Constants.APPLY, Constants.REMOVE)
self.apply_remove_btn.set_slave_filters(
simple_ones=[
*self._frequency_filters_btns,
self._owner.include_undef_freqs,
self._owner.activate_low_freq_filter_btn,
self._owner.activate_up_freq_filter_btn
],
radio_1=self._owner.activate_low_freq_filter_btn,
ruled_by_radio_1=[
self._owner.lower_freq_spinbox,
self._owner.lower_freq_filter_unit,
self._owner.lower_freq_confidence
],
radio_2=self._owner.activate_up_freq_filter_btn,
ruled_by_radio_2=[
self._owner.upper_freq_spinbox,
self._owner.upper_freq_filter_unit,
self._owner.upper_freq_confidence
]
)
connect_events_to_func(
events_to_connect=[self._owner.lower_freq_spinbox.valueChanged,
self._owner.upper_freq_spinbox.valueChanged,
self._owner.lower_freq_filter_unit.currentTextChanged,
self._owner.upper_freq_filter_unit.currentTextChanged,
self._owner.activate_low_freq_filter_btn.toggled],
fun_to_connect=self._set_min_value_upper_limit,
fun_args=[self._owner.lower_freq_filter_unit,
self._owner.lower_freq_spinbox,
self._owner.upper_freq_filter_unit,
self._owner.upper_freq_spinbox]
)
connect_events_to_func(
events_to_connect=[self._owner.lower_freq_spinbox.valueChanged,
self._owner.upper_freq_spinbox.valueChanged,
self._owner.lower_freq_filter_unit.currentTextChanged,
self._owner.upper_freq_filter_unit.currentTextChanged,
self._owner.activate_low_freq_filter_btn.clicked,
self._owner.activate_up_freq_filter_btn.clicked,
self._owner.lower_freq_confidence.valueChanged,
self._owner.upper_freq_confidence.valueChanged],
fun_to_connect=self._set_band_filter_label,
fun_args=[self._owner.activate_low_freq_filter_btn,
self._owner.lower_freq_spinbox,
self._owner.lower_freq_filter_unit,
self._owner.lower_freq_confidence,
self._owner.activate_up_freq_filter_btn,
self._owner.upper_freq_spinbox,
self._owner.upper_freq_filter_unit,
self._owner.upper_freq_confidence,
self._owner.freq_range_lbl]
)
self._owner.activate_low_freq_filter_btn.toggled.connect(
partial(self._owner.activate_if_toggled,
self._owner.activate_low_freq_filter_btn,
self._owner.lower_freq_spinbox,
self._owner.lower_freq_filter_unit,
self._owner.lower_freq_confidence)
)
self._owner.activate_up_freq_filter_btn.toggled.connect(
partial(self._owner.activate_if_toggled,
self._owner.activate_up_freq_filter_btn,
self._owner.upper_freq_spinbox,
self._owner.upper_freq_filter_unit,
self._owner.upper_freq_confidence)
)
@pyqtSlot()
def reset(self):
"""Reset the filter screen."""
self._reset_fb_filters(Ftype.FREQ)
def _ok(self, signal_name):
"""Evalaute if the signal matches the frequency filters."""
if not self.apply_remove_btn.isChecked():
return True
undef_freq = is_undef_freq(self._owner.db.loc[signal_name])
if undef_freq:
if self._owner.include_undef_freqs.isChecked():
return True
else:
return False
signal_freqs = (
safe_cast(self._owner.db.at[signal_name, Signal.INF_FREQ], int),
safe_cast(self._owner.db.at[signal_name, Signal.SUP_FREQ], int)
)
band_filter_ok = False
any_checked = False
for btn, band_limits in zip(self._frequency_filters_btns, Constants.BANDS):
if btn.isChecked():
any_checked = True
if signal_freqs[0] < band_limits.upper and signal_freqs[1] >= band_limits.lower:
band_filter_ok = True
lower_limit_ok = True
upper_limit_ok = True
if self._owner.activate_low_freq_filter_btn.isChecked():
if not signal_freqs[1] >= filters_limit(self._owner.lower_freq_spinbox,
self._owner.lower_freq_filter_unit,
self._owner.lower_freq_confidence, -1):
lower_limit_ok = False
if self._owner.activate_up_freq_filter_btn.isChecked():
if not signal_freqs[0] < filters_limit(self._owner.upper_freq_spinbox,
self._owner.upper_freq_filter_unit,
self._owner.upper_freq_confidence):
upper_limit_ok = False
if any_checked:
return band_filter_ok and lower_limit_ok and upper_limit_ok
else:
return lower_limit_ok and upper_limit_ok
def refresh(self):
"""Extend _BaseFilter.refresh."""
super().refresh()
self._set_band_filter_label(
self._owner.activate_low_band_filter_btn,
self._owner.lower_band_spinbox,
self._owner.lower_band_filter_unit,
self._owner.lower_band_confidence,
self._owner.activate_up_band_filter_btn,
self._owner.upper_band_spinbox,
self._owner.upper_band_filter_unit,
self._owner.upper_band_confidence,
self._owner.band_range_lbl
)
class BandFilter(_BaseFilter, _FreqBandMixIn):
"""Band filter class."""
def __init__(self, owner):
super().__init__(owner)
self.apply_remove_btn = self._owner.apply_remove_band_filter_btn
self.reset_btn = self._owner.reset_band_filters_btn
connect_events_to_func(
events_to_connect=[self._owner.lower_band_spinbox.valueChanged,
self._owner.upper_band_spinbox.valueChanged,
self._owner.lower_band_filter_unit.currentTextChanged,
self._owner.upper_band_filter_unit.currentTextChanged,
self._owner.activate_low_band_filter_btn.toggled],
fun_to_connect=self._set_min_value_upper_limit,
fun_args=[self._owner.lower_band_filter_unit,
self._owner.lower_band_spinbox,
self._owner.upper_band_filter_unit,
self._owner.upper_band_spinbox]
)
connect_events_to_func(
events_to_connect=[self._owner.lower_band_spinbox.valueChanged,
self._owner.upper_band_spinbox.valueChanged,
self._owner.lower_band_filter_unit.currentTextChanged,
self._owner.upper_band_filter_unit.currentTextChanged,
self._owner.activate_low_band_filter_btn.clicked,
self._owner.activate_up_band_filter_btn.clicked,
self._owner.lower_band_confidence.valueChanged,
self._owner.upper_band_confidence.valueChanged],
fun_to_connect=self._set_band_filter_label,
fun_args=[self._owner.activate_low_band_filter_btn,
self._owner.lower_band_spinbox,
self._owner.lower_band_filter_unit,
self._owner.lower_band_confidence,
self._owner.activate_up_band_filter_btn,
self._owner.upper_band_spinbox,
self._owner.upper_band_filter_unit,
self._owner.upper_band_confidence,
self._owner.band_range_lbl]
)
self._owner.activate_low_band_filter_btn.toggled.connect(
partial(self._owner.activate_if_toggled,
self._owner.activate_low_band_filter_btn,
self._owner.lower_band_spinbox,
self._owner.lower_band_filter_unit,
self._owner.lower_band_confidence)
)
self._owner.activate_up_band_filter_btn.toggled.connect(
partial(self._owner.activate_if_toggled,
self._owner.activate_up_band_filter_btn,
self._owner.upper_band_spinbox,
self._owner.upper_band_filter_unit,
self._owner.upper_band_confidence)
)
self.apply_remove_btn.set_texts(Constants.APPLY, Constants.REMOVE)
self.apply_remove_btn.set_slave_filters(
simple_ones=[
self._owner.include_undef_bands,
self._owner.activate_low_band_filter_btn,
self._owner.activate_up_band_filter_btn
],
radio_1=self._owner.activate_low_band_filter_btn,
ruled_by_radio_1=[
self._owner.lower_band_spinbox,
self._owner.lower_band_filter_unit,
self._owner.lower_band_confidence
],
radio_2=self._owner.activate_up_band_filter_btn,
ruled_by_radio_2=[
self._owner.upper_band_spinbox,
self._owner.upper_band_filter_unit,
self._owner.upper_band_confidence
]
)
@pyqtSlot()
def reset(self):
"""Reset the filter screen."""
self._reset_fb_filters(Ftype.BAND)
def _ok(self, signal_name):
"""Evalaute if the signal matches the band filters."""
if not self.apply_remove_btn.isChecked():
return True
undef_band = is_undef_band(self._owner.db.loc[signal_name])
if undef_band:
if self._owner.include_undef_bands.isChecked():
return True
else:
return False
signal_bands = (
safe_cast(self._owner.db.at[signal_name, Signal.INF_BAND], int),
safe_cast(self._owner.db.at[signal_name, Signal.SUP_BAND], int)
)
lower_limit_ok = True
upper_limit_ok = True
if self._owner.activate_low_band_filter_btn.isChecked():
if not signal_bands[1] >= filters_limit(self._owner.lower_band_spinbox,
self._owner.lower_band_filter_unit,
self._owner.lower_band_confidence, -1):
lower_limit_ok = False
if self._owner.activate_up_band_filter_btn.isChecked():
if not signal_bands[0] < filters_limit(self._owner.upper_band_spinbox,
self._owner.upper_band_filter_unit,
self._owner.upper_band_confidence):
upper_limit_ok = False
return lower_limit_ok and upper_limit_ok
def refresh(self):
"""Extend _BaseFilter.refresh."""
super().refresh()
self._set_band_filter_label(
self._owner.activate_low_freq_filter_btn,
self._owner.lower_freq_spinbox,
self._owner.lower_freq_filter_unit,
self._owner.lower_freq_confidence,
self._owner.activate_up_freq_filter_btn,
self._owner.upper_freq_spinbox,
self._owner.upper_freq_filter_unit,
self._owner.upper_freq_confidence,
self._owner.freq_range_lbl
)
class CatFilter(_BaseFilter):
"""Category filter class."""
def __init__(self, owner):
super().__init__(owner)
self.apply_remove_btn = self._owner.apply_remove_cat_filter_btn
self.reset_btn = self._owner.reset_cat_filters_btn
# Order matters!
self._cat_filter_btns = [
self._owner.military_btn,
self._owner.radar_btn,
self._owner.active_btn,
self._owner.inactive_btn,
self._owner.ham_btn,
self._owner.commercial_btn,
self._owner.aviation_btn,
self._owner.marine_btn,
self._owner.analogue_btn,
self._owner.digital_btn,
self._owner.trunked_btn,
self._owner.utility_btn,
self._owner.sat_btn,
self._owner.navigation_btn,
self._owner.interfering_btn,
self._owner.number_stations_btn,
self._owner.time_signal_btn
]
self.apply_remove_btn.set_texts(Constants.APPLY, Constants.REMOVE)
self.apply_remove_btn.set_slave_filters(
simple_ones=[
*self._cat_filter_btns,
self._owner.cat_at_least_one,
self._owner.cat_all
]
)
@pyqtSlot()
def reset(self):
"""Reset the category filter screen."""
uncheck_and_emit(self.apply_remove_btn)
for f in self._cat_filter_btns:
if f.isChecked():
f.setChecked(False)
self._owner.cat_at_least_one.setChecked(True)
def _ok(self, signal_name):
"""Evalaute if the signal matches the category filters."""
if not self.apply_remove_btn.isChecked():
return True
cat_code = self._owner.db.at[signal_name, Signal.CATEGORY_CODE]
cat_checked = 0
positive_cases = 0
for index, cat in enumerate(self._cat_filter_btns):
if cat.isChecked():
cat_checked += 1
if cat_code[index] == '1':
positive_cases += 1
if self._owner.cat_at_least_one.isChecked():
return positive_cases > 0
else:
return cat_checked == positive_cases and cat_checked > 0
class ModeFilter(_BaseFilter):
"""Mode filter class."""
def __init__(self, owner):
super().__init__(owner)
self.apply_remove_btn = self._owner.apply_remove_mode_filter_btn
self.reset_btn = self._owner.reset_mode_filters_btn
self._set_mode_tree_widget()
self._owner.mode_tree_widget.itemSelectionChanged.connect(
self._manage_mode_selections
)
self.apply_remove_btn.set_texts(Constants.APPLY, Constants.REMOVE)
self.apply_remove_btn.set_slave_filters(
simple_ones=[
self._owner.mode_tree_widget,
self._owner.include_unknown_modes_btn
]
)
def _manage_mode_selections(self):
"""Rules the selection of childs items of the 'Mode' QTreeWidget.
If a parent is selected all its children will be selected as well.
"""
selected_items = self._owner.mode_tree_widget.selectedItems()
parents = Constants.MODES.keys()
for parent in parents:
for item in selected_items:
if parent == item.text(0):
for i in range(len(Constants.MODES[parent])):
item.child(i).setSelected(True)
def _set_mode_tree_widget(self):
"""Construct the QTreeWidget for the 'Mode' screen."""
for parent, children in Constants.MODES.items():
iparent = QTreeWidgetItem([parent])
self._owner.mode_tree_widget.addTopLevelItem(iparent)
for child in children:
ichild = QTreeWidgetItem([child])
iparent.addChild(ichild)
self._owner.mode_tree_widget.expandAll()
@pyqtSlot()
def reset(self):
"""Reset the mode filter screen."""
uncheck_and_emit(self.apply_remove_btn)
parents = Constants.MODES.keys()
selected_children = []
for item in self._owner.mode_tree_widget.selectedItems():
if item.text(0) in parents:
item.setSelected(False)
else:
selected_children.append(item)
for children in selected_children:
children.setSelected(False)
if self._owner.include_unknown_modes_btn.isChecked():
self._owner.include_unknown_modes_btn.setChecked(False)
def _ok(self, signal_name):
"""Evalaute if the signal matches the mode filters."""
if not self.apply_remove_btn.isChecked():
return True
signal_mode = self._owner.db.at[signal_name, Signal.MODE]
if signal_mode == Constants.UNKNOWN:
if self._owner.include_unknown_modes_btn.isChecked():
return True
else:
return False
selected_items = [item for item in self._owner.mode_tree_widget.selectedItems()]
selected_items_text = [i.text(0) for i in selected_items]
parents = [
item for item in selected_items_text
if item in Constants.MODES.keys()
]
ok = []
for item in selected_items:
if item.text(0) in parents:
ok.append(item.text(0) in signal_mode)
elif not item.parent().isSelected():
ok.append(item.text(0) == signal_mode)
return any(ok)
class ModulationFilter(_BaseFilter):
"""Modulation filter class."""
def __init__(self, owner):
super().__init__(owner)
self.apply_remove_btn = self._owner.apply_remove_modulation_filter_btn
self.reset_btn = self._owner.reset_modulation_filters_btn
self._owner.search_bar_modulation.textEdited.connect(self._show_matching_modulations)
self.apply_remove_btn.set_texts(Constants.APPLY, Constants.REMOVE)
self.apply_remove_btn.set_slave_filters(
simple_ones=[
self._owner.search_bar_modulation,
self._owner.modulation_list
]
)
self._owner.modulation_list.itemClicked.connect(self._remove_if_unselected_modulation)
@pyqtSlot(QListWidgetItem)
def _remove_if_unselected_modulation(self, item):
"""If an item is unselected from the modulations list, hide the item."""
if not item.isSelected():
self._show_matching_modulations(self.search_bar_modulation.text())
@pyqtSlot(str)
def _show_matching_modulations(self, text):
"""Show the modulations which matches 'text'.
The match criterion is defined in 'show_matching_strings'."""
show_matching_strings(self._owner.modulation_list, text)
@pyqtSlot()
def reset(self):
"""Reset the modulation filter screen."""
uncheck_and_emit(self.apply_remove_btn)
self._owner.search_bar_modulation.setText('')
show_matching_strings(
self._owner.modulation_list,
self._owner.search_bar_modulation.text()
)
for i in range(self._owner.modulation_list.count()):
if self._owner.modulation_list.item(i).isSelected():
self._owner.modulation_list.item(i).setSelected(False)
def _ok(self, signal_name):
"""Evalaute if the signal matches the modulation filters."""
if not self.apply_remove_btn.isChecked():
return True
signal_modulation = get_field_entries(
self._owner.db.at[signal_name, Signal.MODULATION]
)
for item in self._owner.modulation_list.selectedItems():
if item.text() in signal_modulation:
return True
return False
class LocFilter(_BaseFilter):
"""Location filter class."""
def __init__(self, owner):
super().__init__(owner)
self.apply_remove_btn = self._owner.apply_remove_location_filter_btn
self.reset_btn = self._owner.reset_location_filters_btn
self._owner.search_bar_location.textEdited.connect(
self._show_matching_locations
)
self.apply_remove_btn.set_texts(Constants.APPLY, Constants.REMOVE)
self.apply_remove_btn.set_slave_filters(
simple_ones=[
self._owner.search_bar_location,
self._owner.locations_list
]
)
self._owner.locations_list.itemClicked.connect(self._remove_if_unselected_location)
@pyqtSlot(str)
def _show_matching_locations(self, text):
"""Show the locations which matches 'text'.
The match criterion is defined in 'show_matching_strings'."""
show_matching_strings(self._owner.locations_list, text)
@pyqtSlot(QListWidgetItem)
def _remove_if_unselected_location(self, item):
"""If an item is unselected from the locations list, hide the item."""
if not item.isSelected():
self._show_matching_locations(self._owner.search_bar_location.text())
@pyqtSlot()
def reset(self):
"""Reset the location filter screen."""
uncheck_and_emit(self.apply_remove_btn)
self._owner.search_bar_location.setText('')
show_matching_strings(
self._owner.locations_list,
self._owner.search_bar_location.text()
)
for i in range(self._owner.locations_list.count()):
if self._owner.locations_list.item(i).isSelected():
self._owner.locations_list.item(i).setSelected(False)
def _ok(self, signal_name):
"""Evalaute if the signal matches the location filters."""
if not self.apply_remove_btn.isChecked():
return True
signal_locations = get_field_entries(
self._owner.db.at[signal_name, Signal.LOCATION]
)
for item in self._owner.locations_list.selectedItems():
if item.text() in signal_locations:
return True
return False
class ACFFilter(_BaseFilter):
"""Autocorrelation function filter class."""
def __init__(self, owner):
super().__init__(owner)
self.apply_remove_btn = self._owner.apply_remove_acf_filter_btn
self.reset_btn = self._owner.reset_acf_filters_btn
self.apply_remove_btn.set_texts(Constants.APPLY, Constants.REMOVE)
self.apply_remove_btn.set_slave_filters(
simple_ones=[
self._owner.include_undef_acf,
self._owner.include_variable_acf,
self._owner.acf_spinbox,
self._owner.acf_confidence
]
)
self._owner.acf_info_btn.clicked.connect(lambda: webbrowser.open(Constants.ACF_DOCS))
connect_events_to_func(
events_to_connect=[self._owner.acf_spinbox.valueChanged,
self._owner.acf_confidence.valueChanged],
fun_to_connect=self._set_acf_interval_label,
fun_args=None
)
@pyqtSlot()
def _set_acf_interval_label(self):
"""Display the actual acf interval for the search."""
tolerance = self._owner.acf_spinbox.value() * self._owner.acf_confidence.value() / 100
if tolerance > 0:
val = round(self._owner.acf_spinbox.value() - tolerance, Constants.MAX_DIGITS)
to_display = f"Selected range:\n\n{val}" + Constants.RANGE_SEPARATOR \
+ f"{round(self._owner.acf_spinbox.value() + tolerance, Constants.MAX_DIGITS)} ms"
else:
to_display = f"Selected value:\n\n{self._owner.acf_spinbox.value()} ms"
self._owner.acf_range_lbl.setText(to_display)
self._owner.acf_range_lbl.setStyleSheet(f"color: {self._owner.active_color}")
@pyqtSlot()
def reset(self):
"""Reset the acf filter screen."""
uncheck_and_emit(self.apply_remove_btn)
if self._owner.include_undef_acf.isChecked():
self._owner.include_undef_acf.setChecked(False)
if self._owner.include_variable_acf.isChecked():
self._owner.include_variable_acf.setChecked(False)
self._owner.acf_spinbox.setValue(50)
self._owner.acf_confidence.setValue(0)
def _ok(self, signal_name):
"""Evalaute if the signal matches the acf filters."""
if not self.apply_remove_btn.isChecked():
return True
signal_acf_list = self._owner.db.at[signal_name, Signal.ACF]
if signal_acf_list[0].unknown: # Unknown acf are the only acf of the signal.
if self._owner.include_undef_acf.isChecked():
return True
else:
return False
else:
tolerance = self._owner.acf_spinbox.value() * self._owner.acf_confidence.value() / 100
upper_limit = self._owner.acf_spinbox.value() + tolerance
lower_limit = self._owner.acf_spinbox.value() - tolerance
for v in signal_acf_list:
if v.is_numeric:
if lower_limit <= v.numeric_value <= upper_limit:
return True
elif self._owner.include_variable_acf.isChecked():
return True
return False
def refresh(self):
"""Extend _BaseFilter.refresh."""
super().refresh()
self._set_acf_interval_label()
class Filters(QObject):
"""Global filter class.
Provides the information about all the filters. Its only public attribute
is filters, which is a dictionary containing instances of all the filters.
The only exposed methods are reset(), ok(signal_name) and refresh().
The class also connects the apply and reset buttons to the relevant functions."""
def __init__(self, owner):
super().__init__()
self.filters = {
"freq_filter": FreqFilter(owner),
"band_filter": BandFilter(owner),
"cat_filter": CatFilter(owner),
"mode_filter": ModeFilter(owner),
"modulation_filter": ModulationFilter(owner),
"location_filter": LocFilter(owner),
"acf_filter": ACFFilter(owner),
}
self._owner = owner
self._owner.reset_filters_btn.clicked.connect(self._reset)
# Connect Apply and Reset buttons clicks to functions.
for f in self._values:
f.apply_remove_btn.clicked.connect(self._display_signals)
f.reset_btn.clicked.connect(f.reset)
@property
def _values(self):
return self.filters.values()
@pyqtSlot()
def _display_signals(self):
self._owner.display_signals()
@pyqtSlot()
def _reset(self):
"""Reset all the filters."""
for f in self._values:
f.reset()
def ok(self, signal_name):
"""Check whether all the filters are passed."""
return all(f._ok(signal_name) for f in self._values)
def refresh(self):
"""Refresh the relevant widgets when changing theme."""
for f in self._values:
f.refresh()

34
src/os_utilities.py Normal file
View File

@@ -0,0 +1,34 @@
import sys
from constants import SupportedOs
def _is_mac_os():
"""Return True if running OS is Mac."""
return sys.platform == 'darwin'
def _is_win_os():
"""Return True if running OS is Windows."""
return sys.platform == 'win32'
def _is_linux_os():
"""Return True if running OS is Linux."""
return sys.platform == 'linux'
IS_MAC = _is_mac_os()
IS_LINUX = _is_linux_os()
IS_WINDOWS = _is_win_os()
def get_os():
"""Get the name of the current running operating system."""
if IS_WINDOWS:
return SupportedOs.WINDOWS
elif IS_LINUX:
return SupportedOs.LINUX
elif IS_MAC:
return SupportedOs.MAC
else:
raise Exception("ERROR: OS not recognized.")

297
src/spaceweathermanager.py Normal file
View File

@@ -0,0 +1,297 @@
import webbrowser
from PyQt5.QtCore import QObject, pyqtSlot
from constants import Constants, Messages
from switchable_label import SwitchableLabelsIterable
from weatherdata import SpaceWeatherData
from utilities import safe_cast, pop_up
class SpaceWeatherManager(QObject):
"""Class to manage the spaceweather screen."""
def __init__(self, owner):
super().__init__()
self._owner = owner
self._owner.info_now_btn.clicked.connect(
lambda: webbrowser.open(Constants.SPACE_WEATHER_INFO)
)
self._owner.update_now_bar.clicked.connect(self._start_update_space_weather)
self._owner.update_now_bar.set_idle()
self._owner.space_weather_data = SpaceWeatherData()
self._owner.space_weather_data.update_complete.connect(self._update_space_weather)
self.space_weather_labels = (
self._owner.space_weather_lbl_0,
self._owner.space_weather_lbl_1,
self._owner.space_weather_lbl_2,
self._owner.space_weather_lbl_3,
self._owner.space_weather_lbl_4,
self._owner.space_weather_lbl_5,
self._owner.space_weather_lbl_6,
self._owner.space_weather_lbl_7,
self._owner.space_weather_lbl_8
)
for lab in self.space_weather_labels:
lab.set_default_stylesheet()
self._owner.space_weather_label_container.labels = self.space_weather_labels
self._owner.space_weather_label_name_container.labels = [
self._owner.eme_lbl,
self._owner.ms_lbl,
self._owner.muf_lbl,
self._owner.hi_lbl,
self._owner.eu50_lbl,
self._owner.eu70_lbl,
self._owner.eu144_lbl,
self._owner.na_lbl,
self._owner.aurora_lbl
]
self._switchable_r_labels = SwitchableLabelsIterable(
self._owner.r0_now_lbl,
self._owner.r1_now_lbl,
self._owner.r2_now_lbl,
self._owner.r3_now_lbl,
self._owner.r4_now_lbl,
self._owner.r5_now_lbl
)
self._switchable_s_labels = SwitchableLabelsIterable(
self._owner.s0_now_lbl,
self._owner.s1_now_lbl,
self._owner.s2_now_lbl,
self._owner.s3_now_lbl,
self._owner.s4_now_lbl,
self._owner.s5_now_lbl
)
self._switchable_g_now_labels = SwitchableLabelsIterable(
self._owner.g0_now_lbl,
self._owner.g1_now_lbl,
self._owner.g2_now_lbl,
self._owner.g3_now_lbl,
self._owner.g4_now_lbl,
self._owner.g5_now_lbl
)
self._switchable_g_today_labels = SwitchableLabelsIterable(
self._owner.g0_today_lbl,
self._owner.g1_today_lbl,
self._owner.g2_today_lbl,
self._owner.g3_today_lbl,
self._owner.g4_today_lbl,
self._owner.g5_today_lbl
)
self._k_storm_labels = SwitchableLabelsIterable(
self._owner.k_ex_sev_storm_lbl,
self._owner.k_very_sev_storm_lbl,
self._owner.k_sev_storm_lbl,
self._owner.k_maj_storm_lbl,
self._owner.k_min_storm_lbl,
self._owner.k_active_lbl,
self._owner.k_unsettled_lbl,
self._owner.k_quiet_lbl,
self._owner.k_very_quiet_lbl,
self._owner.k_inactive_lbl
)
self._a_storm_labels = SwitchableLabelsIterable(
self._owner.a_sev_storm_lbl,
self._owner.a_maj_storm_lbl,
self._owner.a_min_storm_lbl,
self._owner.a_active_lbl,
self._owner.a_unsettled_lbl,
self._owner.a_quiet_lbl
)
# Used by ThemeManager.
self.refreshable_labels = SwitchableLabelsIterable(
*self._switchable_r_labels,
*self._switchable_s_labels,
*self._switchable_g_now_labels,
*self._switchable_g_today_labels,
*self._k_storm_labels,
*self._a_storm_labels,
self._owner.expected_noise_lbl
)
@pyqtSlot()
def _start_update_space_weather(self):
"""Start the update of the space weather screen.
Start the corresponding thread.
"""
if not self._owner.space_weather_data.is_updating:
self._owner.update_now_bar.set_updating()
self._owner.space_weather_data.update()
@pyqtSlot(bool)
def _update_space_weather(self, status_ok):
"""Update the space weather screen after a successful download.
If the download was not successful throw a warning. In any case remove
the downloaded data.
"""
self._owner.update_now_bar.set_idle()
if status_ok:
xray_long = safe_cast(self._owner.space_weather_data.xray[-1][7], float)
def format_text(letter, power):
return letter + f"{xray_long * 10**power:.1f}"
if xray_long < 1e-8 and xray_long != -1.00e+05:
self._owner.peak_flux_lbl.setText(format_text("<A", 8))
elif xray_long >= 1e-8 and xray_long < 1e-7:
self._owner.peak_flux_lbl.setText(format_text("A", 8))
elif xray_long >= 1e-7 and xray_long < 1e-6:
self._owner.peak_flux_lbl.setText(format_text("B", 7))
elif xray_long >= 1e-6 and xray_long < 1e-5:
self._owner.peak_flux_lbl.setText(format_text("C", 6))
elif xray_long >= 1e-5 and xray_long < 1e-4:
self._owner.peak_flux_lbl.setText(format_text("M", 5))
elif xray_long >= 1e-4:
self._owner.peak_flux_lbl.setText(format_text("X", 4))
elif xray_long == -1.00e+05:
self._owner.peak_flux_lbl.setText("No Data")
if xray_long < 1e-5 and xray_long != -1.00e+05:
self._switchable_r_labels.switch_on(self._owner.r0_now_lbl)
elif xray_long >= 1e-5 and xray_long < 5e-5:
self._switchable_r_labels.switch_on(self._owner.r1_now_lbl)
elif xray_long >= 5e-5 and xray_long < 1e-4:
self._switchable_r_labels.switch_on(self._owner.r2_now_lbl)
elif xray_long >= 1e-4 and xray_long < 1e-3:
self._switchable_r_labels.switch_on(self._owner.r3_now_lbl)
elif xray_long >= 1e-3 and xray_long < 2e-3:
self._switchable_r_labels.switch_on(self._owner.r4_now_lbl)
elif xray_long >= 2e-3:
self._switchable_r_labels.switch_on(self._owner.r5_now_lbl)
elif xray_long == -1.00e+05:
self._switchable_r_labels.switch_off_all()
pro10 = safe_cast(self._owner.space_weather_data.prot_el[-1][8], float)
if pro10 < 10 and pro10 != -1.00e+05:
self._switchable_s_labels.switch_on(self._owner.s0_now_lbl)
elif pro10 >= 10 and pro10 < 100:
self._switchable_s_labels.switch_on(self._owner.s1_now_lbl)
elif pro10 >= 100 and pro10 < 1000:
self._switchable_s_labels.switch_on(self._owner.s2_now_lbl)
elif pro10 >= 1000 and pro10 < 10000:
self._switchable_s_labels.switch_on(self._owner.s3_now_lbl)
elif pro10 >= 10000 and pro10 < 100000:
self._switchable_s_labels.switch_on(self._owner.s4_now_lbl)
elif pro10 >= 100000:
self._switchable_s_labels.switch_on(self._owner.s5_now_lbl)
elif pro10 == -1.00e+05:
self._switchable_s_labels.switch_off_all()
k_index = safe_cast(
self._owner.space_weather_data.ak_index[8][11].replace('.', ''), int
)
self._owner.k_index_lbl.setText(str(k_index))
a_index = safe_cast(
self._owner.space_weather_data.ak_index[7][7].replace('.', ''), int
)
self._owner.a_index_lbl.setText(str(a_index))
if k_index == 0:
self._switchable_g_now_labels.switch_on(self._owner.g0_now_lbl)
self._k_storm_labels.switch_on(self.k_inactive_lbl)
self._owner.expected_noise_lbl.setText(" S0 - S1 (<-120 dBm) ")
elif k_index == 1:
self._switchable_g_now_labels.switch_on(self._owner.g0_now_lbl)
self._k_storm_labels.switch_on(self._owner.k_very_quiet_lbl)
self._owner.expected_noise_lbl.setText(" S0 - S1 (<-120 dBm) ")
elif k_index == 2:
self._switchable_g_now_labels.switch_on(self._owner.g0_now_lbl)
self._k_storm_labels.switch_on(self._owner.k_quiet_lbl)
self._owner.expected_noise_lbl.setText(" S1 - S2 (-115 dBm) ")
elif k_index == 3:
self._switchable_g_now_labels.switch_on(self._owner.g0_now_lbl)
self._k_storm_labels.switch_on(self._owner.k_unsettled_lbl)
self._owner.expected_noise_lbl.setText(" S2 - S3 (-110 dBm) ")
elif k_index == 4:
self._switchable_g_now_labels.switch_on(self._owner.g0_now_lbl)
self._k_storm_labels.switch_on(self._owner.k_active_lbl)
self._owner.expected_noise_lbl.setText(" S3 - S4 (-100 dBm) ")
elif k_index == 5:
self._switchable_g_now_labels.switch_on(self._owner.g1_now_lbl)
self._k_storm_labels.switch_on(self._owner.k_min_storm_lbl)
self._owner.expected_noise_lbl.setText(" S4 - S6 (-90 dBm) ")
elif k_index == 6:
self._switchable_g_now_labels.switch_on(self._owner.g2_now_lbl)
self._k_storm_labels.switch_on(self._owner.k_maj_storm_lbl)
self._owner.expected_noise_lbl.setText(" S6 - S9 (-80 dBm) ")
elif k_index == 7:
self._switchable_g_now_labels.switch_on(self._owner.g3_now_lbl)
self._k_storm_labels.switch_on(self._owner.k_sev_storm_lbl)
self._owner.expected_noise_lbl.setText(" S9 - S20 (>-60 dBm) ")
elif k_index == 8:
self._switchable_g_now_labels.switch_on(self._owner.g4_now_lbl)
self._k_storm_labels.switch_on(self._owner.k_very_sev_storm_lbl)
self._owner.expected_noise_lbl.setText(" S20 - S30 (>-60 dBm) ")
elif k_index == 9:
self._switchable_g_now_labels.switch_on(self._owner.g5_now_lbl)
self._k_storm_labels.switch_on(self._owner.k_ex_sev_storm_lbl)
self._owner.expected_noise_lbl.setText(" S30+ (>>-60 dBm) ")
self._owner.expected_noise_lbl.switch_on()
if a_index >= 0 and a_index < 8:
self._a_storm_labels.switch_on(self._owner.a_quiet_lbl)
elif a_index >= 8 and a_index < 16:
self._a_storm_labels.switch_on(self._owner.a_unsettled_lbl)
elif a_index >= 16 and a_index < 30:
self._a_storm_labels.switch_on(self._owner.a_active_lbl)
elif a_index >= 30 and a_index < 50:
self._a_storm_labels.switch_on(self._owner.a_min_storm_lbl)
elif a_index >= 50 and a_index < 100:
self._a_storm_labels.switch_on(self._owner.a_maj_storm_lbl)
elif a_index >= 100 and a_index < 400:
self._a_storm_labels.switch_on(self._owner.a_sev_storm_lbl)
index = self._owner.space_weather_data.geo_storm[6].index("was") + 1
k_index_24_hmax = safe_cast(
self._owner.space_weather_data.geo_storm[6][index], int
)
if k_index_24_hmax == 0:
self._switchable_g_today_labels.switch_on(self._owner.g0_today_lbl)
elif k_index_24_hmax == 1:
self._switchable_g_today_labels.switch_on(self._owner.g0_today_lbl)
elif k_index_24_hmax == 2:
self._switchable_g_today_labels.switch_on(self._owner.g0_today_lbl)
elif k_index_24_hmax == 3:
self._switchable_g_today_labels.switch_on(self._owner.g0_today_lbl)
elif k_index_24_hmax == 4:
self._switchable_g_today_labels.switch_on(self._owner.g0_today_lbl)
elif k_index_24_hmax == 5:
self._switchable_g_today_labels.switch_on(self._owner.g1_today_lbl)
elif k_index_24_hmax == 6:
self._switchable_g_today_labels.switch_on(self._owner.g2_today_lbl)
elif k_index_24_hmax == 7:
self._switchable_g_today_labels.switch_on(self._owner.g3_today_lbl)
elif k_index_24_hmax == 8:
self._switchable_g_today_labels.switch_on(self._owner.g4_today_lbl)
elif k_index_24_hmax == 9:
self._switchable_g_today_labels.switch_on(self._owner.g5_today_lbl)
val = safe_cast(
self._owner.space_weather_data.ak_index[7][2].replace('.', ''), int
)
self._owner.sfi_lbl.setText(f"{val}")
val = safe_cast(
[x[4] for x in self._owner.space_weather_data.sgas
if "SSN" in x][0], int
)
self._owner.sn_lbl.setText(f"{val:d}")
for label, pixmap in zip(self.space_weather_labels,
self._owner.space_weather_data.images):
label.pixmap = pixmap
label.make_transparent()
label.apply_pixmap()
elif not self._owner.closing:
pop_up(self._owner, title=Messages.BAD_DOWNLOAD,
text=Messages.BAD_DOWNLOAD_MSG).show()
self._owner.space_weather_data.remove_data()

View File

@@ -303,11 +303,11 @@ QTabWidget {
background-color:rgb(247,246,246); background-color:rgb(247,246,246);
} }
QTabWidget::pane { QTabWidget::pane {
border-color: rgb(77,77,77); /* border-color: rgb(77,77,77); */
background-color:rgb(101,101,101); background-color:rgb(101,101,101);
border-style: solid; /* border-style: solid;
border-width: 1px; border-width: 1px;
border-radius: 6px; border-radius: 6px; */
} }
QTabBar::tab { QTabBar::tab {
padding:2px; padding:2px;
@@ -404,4 +404,4 @@ QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal,
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
border: none; border: none;
background: none; background: none;
} }

View File

@@ -1,41 +1,13 @@
from functools import partial from functools import partial
from itertools import chain
import os import os
import re import re
from PyQt5.QtWidgets import QAction, QActionGroup from PyQt5.QtWidgets import QAction, QActionGroup
from PyQt5.QtCore import pyqtSlot from PyQt5.QtCore import pyqtSlot
from PyQt5.QtGui import QPixmap from PyQt5.QtGui import QPixmap
from constants import Constants from constants import Constants, ThemeConstants
from switchable_label import SwitchableLabelsIterable
from utilities import pop_up from utilities import pop_up
class ThemeConstants:
"""Container class for all the theme-related constants."""
FOLDER = "themes"
EXTENSION = ".qss"
ICONS_FOLDER = "icons"
DEFAULT = "dark"
CURRENT = ".current_theme"
COLORS = "colors.txt"
COLOR_SEPARATOR = "="
DEFAULT_ACTIVE_COLOR = "#000000"
DEFAULT_INACTIVE_COLOR = "#9f9f9f"
DEFAULT_OFF_COLORS = "#000000", "#434343"
DEFAULT_ON_COLORS = "#4b79a1", "#283e51"
DEFAULT_TEXT_COLOR = "#ffffff"
THEME_NOT_FOUND = "Theme not found"
MISSING_THEME = "Missing theme in '" + FOLDER + "' folder."
MISSING_THEME_FOLDER = "'" + FOLDER + "'" + " folder not found.\nOnly the basic theme is available."
THEME_FOLDER_NOT_FOUND = "'" + FOLDER + "'" + " folder not found"
DEFAULT_ICONS_PATH = os.path.join(FOLDER, DEFAULT, ICONS_FOLDER)
DEFAULT_SEARCH_LABEL_PATH = os.path.join(DEFAULT_ICONS_PATH, Constants.SEARCH_LABEL_IMG)
DEFAULT_VOLUME_LABEL_PATH = os.path.join(DEFAULT_ICONS_PATH, Constants.VOLUME_LABEL_IMG)
CURRENT_THEME_FILE = os.path.join(FOLDER, CURRENT)
DEFAULT_THEME_PATH = os.path.join(FOLDER, DEFAULT)
class _ColorsHandler: class _ColorsHandler:
"""Manage the theme's secondary colors. """Manage the theme's secondary colors.
@@ -46,7 +18,7 @@ class _ColorsHandler:
Can handle strings representing multiple colors.""" Can handle strings representing multiple colors."""
MAX_COLORS = 2 _MAX_COLORS = 2
def __init__(self, line): def __init__(self, line):
"""Define the color from the string 'line'. """Define the color from the string 'line'.
@@ -76,7 +48,7 @@ class _ColorsHandler:
return bool(re.match(pattern, col)) and len(col) == 7 return bool(re.match(pattern, col)) and len(col) == 7
if not self.is_simple_string: if not self.is_simple_string:
if len(self.color_list) <= self.MAX_COLORS: if len(self.color_list) <= self._MAX_COLORS:
return all(match_ok(c) for c in self.color_list) return all(match_ok(c) for c in self.color_list)
else: else:
return False return False
@@ -120,57 +92,15 @@ class ThemeManager:
self._theme_path = "" self._theme_path = ""
self._current_theme = "" self._current_theme = ""
self._space_weather_labels = SwitchableLabelsIterable( self._owner.spaceweather_screen.refreshable_labels.set(
*list(
chain(
self._owner.switchable_r_labels,
self._owner.switchable_s_labels,
self._owner.switchable_g_now_labels,
self._owner.switchable_g_today_labels,
self._owner.k_storm_labels,
self._owner.a_storm_labels,
[self._owner.expected_noise_lbl]
)
)
)
self._space_weather_labels.set(
"switch_on_colors", "switch_on_colors",
ThemeConstants.DEFAULT_ON_COLORS ThemeConstants.DEFAULT_ON_COLORS
) )
self._space_weather_labels.set( self._owner.spaceweather_screen.refreshable_labels.set(
"switch_off_colors", ThemeConstants.DEFAULT_OFF_COLORS "switch_off_colors", ThemeConstants.DEFAULT_OFF_COLORS
) )
self._theme_names = {} self._theme_names = {}
def _refresh_range_labels(self):
"""Refresh the range-labels."""
self._owner.set_acf_interval_label()
self._owner.set_band_filter_label(
self._owner.activate_low_band_filter_btn,
self._owner.lower_band_spinbox,
self._owner.lower_band_filter_unit,
self._owner.lower_band_confidence,
self._owner.activate_up_band_filter_btn,
self._owner.upper_band_spinbox,
self._owner.upper_band_filter_unit,
self._owner.upper_band_confidence,
self._owner.band_range_lbl
)
self._owner.set_band_filter_label(
self._owner.activate_low_freq_filter_btn,
self._owner.lower_freq_spinbox,
self._owner.lower_freq_filter_unit,
self._owner.lower_freq_confidence,
self._owner.activate_up_freq_filter_btn,
self._owner.upper_freq_spinbox,
self._owner.upper_freq_filter_unit,
self._owner.upper_freq_confidence,
self._owner.freq_range_lbl
)
@pyqtSlot() @pyqtSlot()
def _apply(self, theme_path): def _apply(self, theme_path):
"""Apply the selected theme. """Apply the selected theme.
@@ -185,12 +115,12 @@ class ThemeManager:
item=self._owner.signals_list.currentItem(), item=self._owner.signals_list.currentItem(),
previous_item=None previous_item=None
) )
self._refresh_range_labels() self._owner.filters.refresh()
self._owner.audio_widget.refresh_btns_colors( self._owner.audio_widget.refresh(
self._owner.active_color, self._owner.active_color,
self._owner.inactive_color self._owner.inactive_color
) )
self._space_weather_labels.refresh() self._owner.spaceweather_screen.refreshable_labels.refresh()
else: else:
pop_up(self._owner, title=ThemeConstants.THEME_NOT_FOUND, pop_up(self._owner, title=ThemeConstants.THEME_NOT_FOUND,
text=ThemeConstants.MISSING_THEME).show() text=ThemeConstants.MISSING_THEME).show()
@@ -222,7 +152,8 @@ class ThemeManager:
new_theme = ag.addAction( new_theme = ag.addAction(
QAction( QAction(
theme_name, theme_name,
self._owner, checkable=True self._owner,
checkable=True
) )
) )
self._owner.menu_themes.addAction(new_theme) self._owner.menu_themes.addAction(new_theme)
@@ -297,20 +228,20 @@ class ThemeManager:
inactive_color_ok = True inactive_color_ok = True
if color.quality == Constants.TEXT_COLOR: if color.quality == Constants.TEXT_COLOR:
text_color_ok = True text_color_ok = True
self._space_weather_labels.set( self._owner.spaceweather_screen.refreshable_labels.set(
"text_color", "text_color",
color.color_str color.color_str
) )
for color in color_handler.double_color_list: for color in color_handler.double_color_list:
if color.quality == Constants.LABEL_ON_COLOR: if color.quality == Constants.LABEL_ON_COLOR:
switch_on_color_ok = True switch_on_color_ok = True
self._space_weather_labels.set( self._owner.spaceweather_screen.refreshable_labels.set(
"switch_on_colors", "switch_on_colors",
color.color_list color.color_list
) )
if color.quality == Constants.LABEL_OFF_COLOR: if color.quality == Constants.LABEL_OFF_COLOR:
switch_off_color_ok = True switch_off_color_ok = True
self._space_weather_labels.set( self._owner.spaceweather_screen.refreshable_labels.set(
"switch_off_colors", "switch_off_colors",
color.color_list color.color_list
) )
@@ -320,17 +251,17 @@ class ThemeManager:
self._owner.inactive_color = ThemeConstants.DEFAULT_INACTIVE_COLOR self._owner.inactive_color = ThemeConstants.DEFAULT_INACTIVE_COLOR
if not (switch_on_color_ok and switch_off_color_ok): if not (switch_on_color_ok and switch_off_color_ok):
self._space_weather_labels.set( self._owner.spaceweather_screen.refreshable_labels.set(
"switch_on_colors", "switch_on_colors",
ThemeConstants.DEFAULT_ON_COLORS ThemeConstants.DEFAULT_ON_COLORS
) )
self._space_weather_labels.set( self._owner.spaceweather_screen.refreshable_labels.set(
"switch_off_colors", "switch_off_colors",
ThemeConstants.DEFAULT_OFF_COLORS ThemeConstants.DEFAULT_OFF_COLORS
) )
if not text_color_ok: if not text_color_ok:
self._space_weather_labels.set( self._owner.spaceweather_screen.refreshable_labels.set(
"text_color", "text_color",
ThemeConstants.DEFAULT_TEXT_COLOR ThemeConstants.DEFAULT_TEXT_COLOR
) )
@@ -338,31 +269,37 @@ class ThemeManager:
try: try:
with open(ThemeConstants.CURRENT_THEME_FILE, "w") as current_theme: with open(ThemeConstants.CURRENT_THEME_FILE, "w") as current_theme:
current_theme.write(self._theme_path) current_theme.write(os.path.basename(self._theme_path))
except Exception: except Exception:
pass pass
def apply_default_theme(self):
"""Apply the default theme if no theme is set or the theme name is invalid."""
try:
self._theme_names[
self._pretty_name(ThemeConstants.DEFAULT)
].setChecked(True)
except Exception:
pop_up(
self._owner,
title=ThemeConstants.THEME_NOT_FOUND,
text=ThemeConstants.MISSING_THEME
).show()
else:
self._apply(ThemeConstants.DEFAULT_THEME_PATH)
def start(self): def start(self):
"""Start the theme manager.""" """Start the theme manager."""
self._detect_themes() self._detect_themes()
if os.path.exists(ThemeConstants.CURRENT_THEME_FILE): if os.path.exists(ThemeConstants.CURRENT_THEME_FILE):
with open(ThemeConstants.CURRENT_THEME_FILE, "r") as current_theme_path: with open(ThemeConstants.CURRENT_THEME_FILE, "r") as current_theme_name:
theme_path = current_theme_path.read() theme_path = os.path.join(ThemeConstants.FOLDER, current_theme_name.read())
theme_name = self._pretty_name(os.path.basename(theme_path)) theme_name = self._pretty_name(os.path.basename(theme_path))
try: try:
self._theme_names[theme_name].setChecked(True) self._theme_names[theme_name].setChecked(True)
except Exception: except Exception:
pop_up(self._owner, title=ThemeConstants.THEME_NOT_FOUND, self.apply_default_theme()
text=ThemeConstants.MISSING_THEME).show()
else: else:
self._apply(theme_path) self._apply(theme_path)
else: else:
try: self.apply_default_theme()
self._theme_names[
self._pretty_name(ThemeConstants.DEFAULT)
].setChecked(True)
except Exception:
pop_up(self._owner, title=ThemeConstants.THEME_NOT_FOUND,
text=ThemeConstants.MISSING_THEME).show()
else:
self._apply(ThemeConstants.DEFAULT_THEME_PATH)

View File

@@ -2,18 +2,18 @@ import asyncio
from enum import Enum, auto from enum import Enum, auto
from io import BytesIO from io import BytesIO
from math import ceil from math import ceil
import os.path import ssl
from shutil import rmtree
from time import perf_counter from time import perf_counter
from zipfile import ZipFile
import aiohttp import aiohttp
import urllib3 from PyQt5.QtCore import QThread, pyqtSignal, pyqtSlot
from PyQt5.QtCore import QThread, pyqtSignal from constants import Constants
from constants import Constants, Database, ChecksumWhat
from utilities import checksum_ok from utilities import checksum_ok
from web_utilities import (
get_cacert_file,
get_pool_manager,
)
# Needed for pyinstaller compilation. # Needed for pyinstaller compilation.
import encodings.idna import encodings.idna # noqa: 401
class ThreadStatus(Enum): class ThreadStatus(Enum):
@@ -39,30 +39,31 @@ class BaseDownloadThread(QThread):
super().__init__(parent) super().__init__(parent)
self.status = ThreadStatus.UNDEFINED self.status = ThreadStatus.UNDEFINED
def __del__(self): # def __del__(self):
"""Force the termination of the thread.""" # """Force the termination of the thread."""
self.terminate() # self.terminate()
self.wait() # self.wait()
class DownloadThread(BaseDownloadThread): class DownloadThread(BaseDownloadThread):
"""Subclass BaseDownloadThread. Download the database, images and audio samples.""" """Subclass BaseDownloadThread. Download the database folder. Used also for software updates."""
progress = pyqtSignal(int) progress = pyqtSignal(int)
speed_progress = pyqtSignal(float) speed_progress = pyqtSignal(float)
_CHUNK = 128 * 1024 _CHUNK = 128 * 1024
_MEGA = 1024**2
_DELTAT = 2 _DELTAT = 2
def __init__(self): def __init__(self, min_bytes=1024**2):
"""Just call super().__init__.""" """Just call super().__init__."""
self._db = None
self._exit_call = False
super().__init__() super().__init__()
self._min_bytes = min_bytes
self._data = None
self._exit_call = False
self._target = None
def _pretty_len(self, byte_obj): def _pretty_len(self, byte_obj):
"""Return a well-formatted number of downloaded MB.""" """Return a well-formatted number of downloaded MB."""
mega = len(byte_obj) / self._MEGA mega = len(byte_obj) / self._min_bytes
if mega.is_integer(): if mega.is_integer():
return int(mega) return int(mega)
else: else:
@@ -71,93 +72,154 @@ class DownloadThread(BaseDownloadThread):
def _get_download_speed(self, data, delta): def _get_download_speed(self, data, delta):
"""Return the download speed in MB/s.""" """Return the download speed in MB/s."""
return round( return round(
(len(data) / self._MEGA) / delta, 2 (len(data) / self._min_bytes) / delta, 2
) )
@pyqtSlot()
def set_exit(self): def set_exit(self):
"""Time to shutdown the thread.
Executed in the main thread."""
self._exit_call = True self._exit_call = True
def start(self, target):
"""Start the thread. Set the correct download options first."""
self._target = target
super().start()
def _download_loop(self):
"""Read a chunck of the downloaded data at every iteration."""
raw_data = bytes(0)
sub_data = bytes(0)
start = perf_counter()
prev_downloaded = 0
while True:
try:
data = self._data.read(self._CHUNK)
except Exception:
raise _SlowConnError
else:
delta = perf_counter() - start
if not data:
break
raw_data += data
sub_data += data
# Emit a progress signal only if at least self._min_bytes has been downloaded.
if len(raw_data) - prev_downloaded >= self._min_bytes:
prev_downloaded = len(raw_data)
self.progress.emit(self._pretty_len(raw_data))
if delta >= self._DELTAT:
self.speed_progress.emit(
self._get_download_speed(sub_data, delta)
)
sub_data = bytes(0)
start = perf_counter()
if self._exit_call:
self._data.release_conn()
break
return raw_data
def run(self): def run(self):
"""Override QThread.run. Download the database, images and audio samples. """Override QThread.run. Download the database, images and audio samples.
Handle all possible exceptions. Also extract the files Handle all possible exceptions. Also extract the files
in the local folder.""" in the destination folder."""
self.status = ThreadStatus.UNDEFINED self.status = ThreadStatus.UNDEFINED
self._db = None self._data = None
raw_data = bytes(0)
sub_data = bytes(0)
try: try:
self._db = urllib3.PoolManager().request( self._data = get_pool_manager().request(
'GET', 'GET',
Database.LINK_LOC, self._target.url,
preload_content=False, preload_content=False,
timeout=4.0
) )
start = perf_counter() raw_data = self._download_loop()
prev_downloaded = 0 if self._exit_call:
while True: self._exit_call = False
try: return
data = self._db.read(self._CHUNK)
except Exception:
raise _SlowConnError
else:
delta = perf_counter() - start
if not data:
break
raw_data += data
sub_data += data
# Emit a progress signal only if at least 1 MB has been downloaded.
if len(raw_data) - prev_downloaded >= self._MEGA:
prev_downloaded = len(raw_data)
self.progress.emit(self._pretty_len(raw_data))
if delta >= self._DELTAT:
self.speed_progress.emit(
self._get_download_speed(sub_data, delta)
)
sub_data = bytes(0)
start = perf_counter()
if self._exit_call:
self._exit_call = False
self._db.release_conn()
return
except Exception as e: # No (or bad) internet connection. except Exception as e: # No (or bad) internet connection.
self._db.release_conn() self._data.release_conn()
if isinstance(e, _SlowConnError): if isinstance(e, _SlowConnError):
self.status = ThreadStatus.SLOW_CONN_ERR self.status = ThreadStatus.SLOW_CONN_ERR
else: else:
self.status = ThreadStatus.NO_CONNECTION_ERR self.status = ThreadStatus.NO_CONNECTION_ERR
return return
if self._db.status != 200: if self._data.status != 200:
self.status = ThreadStatus.BAD_DOWNLOAD_ERR self.status = ThreadStatus.BAD_DOWNLOAD_ERR
return return
try: if self._wrong_checksum(raw_data):
is_checksum_ok = checksum_ok(raw_data, ChecksumWhat.FOLDER)
except Exception: # checksum_ok unable to connect to the reference.
self.status = ThreadStatus.NO_CONNECTION_ERR
return return
self._target.delete_files()
self._extract(raw_data)
def _wrong_checksum(self, raw_data):
"""Verify the checksum of the downloaded data and set the status accordingly."""
try:
is_checksum_ok = checksum_ok(raw_data, self._target.hash_code)
except Exception: # Invalid hash code.
self.status = ThreadStatus.NO_CONNECTION_ERR
return True
else: else:
if not is_checksum_ok: if not is_checksum_ok:
self.status = ThreadStatus.BAD_DOWNLOAD_ERR self.status = ThreadStatus.BAD_DOWNLOAD_ERR
return return True
if os.path.exists(Constants.DATA_FOLDER): return False
rmtree(Constants.DATA_FOLDER)
def _extract(self, raw_data):
"""Unzip and save the downloaded data into the destination folder."""
try: try:
self.progress.emit(Constants.EXTRACTING_CODE) self.progress.emit(Constants.EXTRACTING_CODE)
self.speed_progress.emit(Constants.ZERO_FINAL_SPEED) self.speed_progress.emit(Constants.ZERO_FINAL_SPEED)
with ZipFile(BytesIO(raw_data)) as zipped: with self._target.Extractor.open(fileobj=BytesIO(raw_data)) as zipped:
zipped.extractall() zipped.extractall(path=self._target.dest_path)
except Exception: except Exception:
self.status = ThreadStatus.UNKNOWN_ERR self.status = ThreadStatus.UNKNOWN_ERR
else: else:
self.status = ThreadStatus.OK self.status = ThreadStatus.OK
class UpdatesControllerThread(BaseDownloadThread):
on_success = pyqtSignal(bool)
def __init__(self, version_controller):
super().__init__()
self.version_controller = version_controller
def run(self):
if self.version_controller.update():
self.on_success.emit(True)
else:
self.on_success.emit(False)
# class GenercWorkerThread(BaseDownloadThread):
# def __init__(self, func, *args, **kwargs):
# super().__init__()
# self._args = args
# self._kwargs = kwargs
# self._func
# def run(self):
# self.status = ThreadStatus.UNDEFINED
# try:
# self._func(self._args, self._kwargs)
# except Exception:
# self.status = ThreadStatus.UNKNOWN_ERR
# else:
# self.status = ThreadStatus.OK
class _AsyncDownloader: class _AsyncDownloader:
"""Mixin class for asynchronous threads.""" """Mixin class for asynchronous threads."""
async def _download_resource(self, session, link): async def _download_resource(self, session, link):
"""Return the content of 'link' as bytes.""" """Return the content of 'link' as bytes."""
resp = await session.get(link) ssl_context = ssl.create_default_context(
purpose=ssl.Purpose.SERVER_AUTH,
cafile=get_cacert_file()
)
resp = await session.get(link, ssl=ssl_context)
return await resp.read() return await resp.read()

200
src/updater.py Normal file
View File

@@ -0,0 +1,200 @@
import argparse
import os
import os.path
import sys
from PyQt5.QtCore import QObject, QProcess
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import QApplication, qApp
from download_window import DownloadWindow
from constants import Constants, DownloadTarget
from downloadtargetfactory import get_download_target
__VERSION__ = "0.0.1"
# Global stylesheet.
stylesheet = """
/*************************************
Main Window and Splitters
**************************************/
QWidget:window {
background-color: #29353B;
}
/*************************************
Main menu (Bar)
**************************************/
QMenuBar {
background-color: transparent;
color: #AFBDC4;
}
QMenuBar::item {
background-color: transparent;
}
QMenuBar::item:disabled {
color: gray;
}
QMenuBar::item:selected {
color: #FFFFFF;
border-bottom: 2px solid #88cc00;
}
QMenuBar::item:pressed {
color: #FFFFFF;
border-bottom: 2px solid #88cc00;
}
QToolBar {
background-color: transparent;
border: 1px solid transparent;
}
QToolBar:handle {
background-color: transparent;
border-left: 2px dotted #80CBC4;
color: transparent;
}
QToolBar::separator {
border: 0;
}
QMenu {
background-color: #263238;
color: #AFBDC4;
}
QMenu::item:selected {
color: #FFFFFF;
}
QMenu::item:pressed {
color: #FFFFFF;
}
QMenu::separator {
background-color: transparent;
height: 1px;
margin-left: 10px;
margin-right: 10px;
margin-top: 5px;
margin-bottom: 5px;
}
/*************************************
Progressbar
**************************************/
QProgressBar
{
border: 2px solid grey;
border-radius: 5px;
text-align: center;
}
QProgressBar::chunk
{
background-color: #88cc00;
width: 2.15px;
margin: 0.5px;
}
/*************************************
Labels and Rich Text boxes
**************************************/
QLabel {
background-color: transparent;
color: #CFD8DC;
}
QDialog {
background-color: transparent;
color: #949a9c;
}
QTextBrowser {
background-color: transparent;
color: #949a9c;
}
/*************************************
Buttons
**************************************/
QPushButton {
background-color: transparent;
color: #AFBDC4;
border: 1px solid transparent;
padding: 4px 22px;
}
QPushButton:hover {
border-left: 2px solid #88cc00;
border-right: 2px solid #88cc00;
color: #FFFFFF;
}
QPushButton:pressed {
color: #FFFFFF;
}
QPushButton:disabled {
color:#546E7A;
}
QPushButton:checked {
color: #88cc00;
}
"""
class _ArtemisUpdater(QObject):
"""Updater of the main software."""
def __init__(self, target):
super().__init__()
self.target = get_download_target(DownloadTarget.SOFTWARE, target)
self.download_window = DownloadWindow()
self.download_window.setStyleSheet(stylesheet)
self.download_window.cancel_btn.clicked.connect(qApp.quit)
self.download_window.complete.connect(self.start_main_program)
def start(self):
"""Close the main program and start the download."""
self.download_window.activate(self.target)
def init_ok(self):
return self.target.url and self.target.hash_code and self.target.size > 0
def start_main_program(self):
"""Restart the (updated) main program and close the updater."""
self.download_window.setVisible(False)
artemis = QProcess()
try:
artemis.startDetached(Constants.EXECUTABLE_NAME)
except BaseException:
pass
qApp.quit()
if __name__ == '__main__':
parser = argparse.ArgumentParser(prog='Artemis Updater')
parser.add_argument("url", nargs="?", default="", type=str, help="Download url")
parser.add_argument("hash_code", nargs="?", default="", type=str, help="sha256 of the file")
parser.add_argument("size", nargs="?", default=0, type=int, help="Size (KB) of the file")
parser.add_argument('--version', action='version', version=__VERSION__)
args = parser.parse_args()
my_app = QApplication(sys.argv)
ARTEMIS_ICON = os.path.join(":", "icon", "default_pics", "Artemis3.500px.png")
img = QPixmap(ARTEMIS_ICON)
updater = _ArtemisUpdater(args)
if not updater.init_ok():
updater.start_main_program()
else:
updater.start()
sys.exit(my_app.exec_())

141
src/updatescontroller.py Normal file
View File

@@ -0,0 +1,141 @@
import subprocess as sp
import webbrowser
from PyQt5.QtCore import QObject, pyqtSlot, QProcess
from PyQt5.QtWidgets import QMessageBox, qApp
from constants import Constants, Messages, DownloadTarget
from downloadtargetfactory import get_download_target
from utilities import pop_up
from os_utilities import IS_MAC
from executable_utilities import IS_BINARY
from threads import UpdatesControllerThread
from versioncontroller import VersionController
class UpdatesController(QObject):
def __init__(self, current_version, owner):
super().__init__()
self._owner = owner
self._download_window = self._owner.download_window
self._current_version = current_version
self.version_controller = VersionController()
self._updates_thread = UpdatesControllerThread(self.version_controller)
self._updates_thread.on_success.connect(self._startup_updates_check)
def start(self):
"""Start the thread."""
if IS_BINARY:
self._updates_thread.start()
@pyqtSlot()
def start_verify_software_version(self):
if not IS_BINARY:
pop_up(
self._owner,
title=Messages.FEATURE_NOT_AVAILABLE,
text=Messages.SCRIPT_NOT_UPDATE
).show()
return
if not self._download_window.isVisible():
self._updates_thread.start()
@pyqtSlot(bool)
def _verify_software_version(self, success):
"""Verify if there is a new software version.
Otherwise notify the user that the software is up to date."""
if not self._download_window.isVisible():
if success:
new_version_found = self._check_new_version()
if not new_version_found:
pop_up(
self._owner,
title=Messages.UP_TO_DATE,
text=Messages.UP_TO_DATE_MSG
).show()
else:
pop_up(
self._owner,
title=Messages.NO_CONNECTION,
text=Messages.NO_CONNECTION_MSG
).show()
@pyqtSlot(bool)
def _startup_updates_check(self, success):
self._updates_thread.on_success.disconnect()
self._updates_thread.on_success.connect(self._verify_software_version)
if success:
if not self._check_new_version():
# Check for a new version of the updater only if Artemis is up to date.
self._check_updater_version()
def _check_new_version(self):
"""Check whether there is a new software version available.
Does something only if the running program is a compiled version."""
if not IS_BINARY:
return None
latest_version = self.version_controller.software.version
if latest_version is None:
return False
if latest_version == self._current_version:
return False
answer = pop_up(
self._owner,
title=Messages.NEW_VERSION_AVAILABLE,
text=Messages.NEW_VERSION_MSG(latest_version),
informative_text=Messages.DOWNLOAD_SUGG_MSG,
is_question=True,
).exec()
if answer == QMessageBox.Yes:
if IS_MAC:
webbrowser.open(self.version_controller.software.url)
else:
updater = QProcess()
command = Constants.UPDATER_SOFTWARE + " " + \
self.version_controller.software.url + \
" " + self.version_controller.software.hash_code + \
" " + str(self.version_controller.software.size)
try:
updater.startDetached(command)
except BaseException:
pass
else:
qApp.quit()
return True
def _check_updater_version(self):
"""Check is a new version of the updater is available.
If so, ask to download the new version.
If the software is not a compiled version, the function is a NOP."""
if not IS_BINARY or IS_MAC:
return
latest_updater_version = self.version_controller.updater.version
try:
with sp.Popen(
[Constants.UPDATER_SOFTWARE, "--version"],
encoding="UTF-8",
stdout=sp.PIPE,
stderr=sp.STDOUT,
stdin=sp.DEVNULL # Needed to avoid OsError: [WinError 6] The handle is invalid.
) as proc:
updater_version = proc.stdout.read().rstrip("\r\n") # Strip any possible newline, to be sure.
except Exception:
updater_version = latest_updater_version
if latest_updater_version is None:
return
if updater_version != latest_updater_version:
answer = pop_up(
self._owner,
title=Messages.UPDATES_AVAILABALE,
text=Messages.UPDATES_MSG,
is_question=True,
).exec()
if answer == QMessageBox.Yes:
self._download_window.activate(
get_download_target(
DownloadTarget.UPDATER,
self.version_controller.updater
)
)

View File

@@ -1,21 +1,29 @@
from functools import partial from functools import partial
import hashlib import hashlib
import sys
import os
from pandas import read_csv
from PyQt5.QtWidgets import QMessageBox from PyQt5.QtWidgets import QMessageBox
from constants import Constants, Signal
from constants import Constants, Signal, Database, ChecksumWhat
def resource_path(relative_path): class UniqueMessageBox(QMessageBox):
"""Get absolute path to resource, works for dev and for PyInstaller.""" """Subclass of QMessageBox. Overrides only the exec method.
try:
base_path = sys._MEIPASS Only one instance of this class can execute super().exec() exec at a given time.
except Exception: If another instance is the the exec loop, calling exec simply return None."""
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path) _open_message = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def exec(self):
"""Overrides QMessageBox.exec. Call the parent method if there are no
other instances executing exec. Otherwise return None,"""
if UniqueMessageBox._open_message:
return None
UniqueMessageBox._open_message = True
answer = super().exec()
UniqueMessageBox._open_message = False
return answer
def uncheck_and_emit(button): def uncheck_and_emit(button):
@@ -25,7 +33,30 @@ def uncheck_and_emit(button):
button.clicked.emit() button.clicked.emit()
def pop_up(cls, title, text, def show_matching_strings(list_elements, text):
"""Show all elements of QListWidget that matches (even partially) a target text.
Arguments:
list_elements -- the QListWidget
text -- the target text."""
for index in range(list_elements.count()):
item = list_elements.item(index)
if text.lower() in item.text().lower() or item.isSelected():
item.setHidden(False)
else:
item.setHidden(True)
def get_field_entries(db_entry, separator=Constants.FIELD_SEPARATOR):
"""Take a database entry and optionally a separator string.
Return a list obtained by splitting the signal field with separator."""
return [
x.strip() for x in db_entry.split(separator)
]
def pop_up(instance, title, text,
informative_text=None, informative_text=None,
connection=None, connection=None,
is_question=False, is_question=False,
@@ -37,7 +68,7 @@ def pop_up(cls, title, text,
connection -- a callable to connect the message when emitting the finished signal. connection -- a callable to connect the message when emitting the finished signal.
is_question -- whether the message contains a question. is_question -- whether the message contains a question.
default_btn -- the default button for the possible answer to the question.""" default_btn -- the default button for the possible answer to the question."""
msg = QMessageBox(cls) msg = UniqueMessageBox(instance)
msg.setWindowTitle(title) msg.setWindowTitle(title)
msg.setText(text) msg.setText(text)
if informative_text: if informative_text:
@@ -51,24 +82,15 @@ def pop_up(cls, title, text,
return msg return msg
def checksum_ok(data, what): def checksum_ok(data, reference_hash_code):
"""Check whether the checksum of the 'data' argument is correct.""" """Check whether the checksum of the 'data' argument is correct.
Expects a sha256 code as argument."""
if reference_hash_code is None:
raise Exception("ERROR: Invalid hash code.")
code = hashlib.sha256() code = hashlib.sha256()
code.update(data) code.update(data)
if what is ChecksumWhat.FOLDER: return code.hexdigest() == reference_hash_code
n = 0
elif what is ChecksumWhat.DB:
n = 1
else:
raise ValueError("Wrong entry name.")
try:
reference = read_csv(
Database.LINK_REF,
delimiter=Database.DELIMITER
).iat[-1, n]
except Exception:
raise
return code.hexdigest() == reference
def connect_events_to_func(events_to_connect, fun_to_connect, fun_args): def connect_events_to_func(events_to_connect, fun_to_connect, fun_args):

111
src/versioncontroller.py Normal file
View File

@@ -0,0 +1,111 @@
from io import BytesIO
import json
from constants import Constants
from os_utilities import get_os
from web_utilities import download_file
"""This module exposes just one class: VersionController.
All the relevant information can be accessed with the dot notation on an instance of such class, e.g.:
version_controller.software.hash_code
is the hash_code of the latest release of the software running on the current OS."""
def _download_versions_file():
"""Download the json file containing all the information
about the latest version of the software. Return a dictionary
containing only the information for the running OS.
Return a dictionary from a json with the following structure:
{
"windows": {
"software": {
"version": "...",
"url": "...",
"hash_code": "...",
"size": ...
},
"updater": {
"version": "...",
"url": "...",
"hash_code": "...",
"size": ...
}
},
"linux": {
"software": {
"version": "...",
"url": "...",
"hash_code": "...",
"size": ...
},
"updater": {
"version": "...",
"url": "...",
"hash_code": "...",
"size": ...
}
},
"mac": {
"software": {
"version": "...",
"url": "...",
"hash_code": "...",
"size": ...
},
"updater": {
"version": "...",
"url": "...",
"hash_code": "...",
"size": ...
}
}
}
"""
try:
version_dict = json.load(
BytesIO(download_file(Constants.VERSION_LINK))
)[get_os()]
except Exception:
return None
else:
return version_dict
class VersionController:
"""Dynamically create attributes corresponding to elements of a dictionary.
Used to get updates information."""
def __init__(self, dct=None):
"""Initialize the dictionary"""
super().__init__()
self._dct = dct
def __getattr__(self, attr):
"""Override super().__getattr__. Dynamically create new attributes
corresponding to elements of the diciotnary."""
if self._dct is None:
if not self.update():
return None
try:
dct_element = self._dct[attr]
except Exception("ERROR: Invalid attribute!"):
return None
else:
if isinstance(dct_element, dict):
setattr(self, attr, type(self)(dct_element))
else:
setattr(self, attr, dct_element)
return getattr(self, attr)
def update(self):
"""Reset the dictionary to the correspondig json file containing
the latest version information. Call this function inside a Qthread."""
dct = _download_versions_file()
if dct is not None:
self._dct = dct
return True
else:
return False

View File

@@ -1,10 +1,12 @@
import re import re
from PyQt5.QtGui import QPixmap from PyQt5.QtGui import QPixmap
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject
from threads import (BaseDownloadThread, from threads import (
UpdateSpaceWeatherThread, BaseDownloadThread,
ThreadStatus, UpdateSpaceWeatherThread,
UpdateForecastThread) ThreadStatus,
UpdateForecastThread
)
from constants import Constants from constants import Constants
from switchable_label import MultiColorSwitchableLabel from switchable_label import MultiColorSwitchableLabel
from utilities import safe_cast from utilities import safe_cast

51
src/web_utilities.py Normal file
View File

@@ -0,0 +1,51 @@
import os
import sys
import urllib3
from constants import Database
from executable_utilities import IS_BINARY
def get_cacert_file():
"""Return the path to the cacert.pem file."""
if IS_BINARY:
ca_certs = os.path.join(sys._MEIPASS, 'cacert.pem')
else:
ca_certs = 'cacert.pem'
return ca_certs
def get_pool_manager():
"""Return a urllib3.PoolManager object."""
return urllib3.PoolManager(ca_certs=get_cacert_file())
def download_file(url, encoding=""):
resp = get_pool_manager().request(
'GET',
url,
preload_content=True,
timeout=4.0
).data
if encoding:
return resp.decode(encoding)
return resp
def _download_multiline_file_as_list(url=Database.LINK_REF):
"""Download a text file and return the last line as a list.
The downloaded file is a csv file with columns (last version == last line):
data.zip_SHA256 | db.csv_SHA256 | Version | Creation_date"""
try:
f = download_file(url, encoding="UTF-8").splitlines()[-1].split(Database.DELIMITER)
except Exception:
return None
return f
def get_folder_hash_code():
return _download_multiline_file_as_list()[0]
def get_db_hash_code():
return _download_multiline_file_as_list()[1]

View File

@@ -60,19 +60,3 @@ This folder contains a third option to run Artemis 3 on your pc. The method of i
> ### MacOS: > ### MacOS:
> >
> 1. To Be Completed... > 1. To Be Completed...
## License
This program (ARTEMIS 3, 2014-2019) is free software: you can redistribute it and/or modify it under the terms of the GNU 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 General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see: www.gnu.org/licenses
## Thanks
* **Marco Dalla Tiezza** - *Artemis I-II developer, DB parsing, Website*
* [**Alessandro Ceccato**](https://github.com/alessandro90 "GitHub profile") - *Artemis III lead developer*
* **Paolo Romani (IZ1MLL)** - *Lead β Tester, RF specialist*
* **Carl Colena** - *Sigidwiki admin, β Tester, Signals expert*
* **Marco Bortoli** - *macOS deployment, β Tester*
* **Pierpaolo Pravatto** - *Wiki page, β Tester*
* **Francesco Capostagno, Luca, Pietro** - *β Tester*