Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e69f6a2bee | ||
|
|
c16de1f1eb | ||
|
|
bf3efd11a4 | ||
|
|
4a26ac58d0 | ||
|
|
26f329740d | ||
|
|
0825b8f7d1 | ||
|
|
896fef5f2d | ||
|
|
84dc68dd55 | ||
|
|
cfd302d3ca | ||
|
|
3c6658d19d | ||
|
|
5af0faaa65 | ||
|
|
ce2cfdc76a | ||
|
|
6e0a161b89 | ||
|
|
940c6a0d58 | ||
|
|
194b5c8fb8 | ||
|
|
4e1b3f24c5 | ||
|
|
eaeb51de65 | ||
|
|
995696f11a | ||
|
|
7503b6bb14 | ||
|
|
b867ca849d | ||
|
|
ab32fbbf98 | ||
|
|
bcd24cc035 | ||
|
|
5908110a43 | ||
|
|
808a63f57c | ||
|
|
e03d2f51ba | ||
|
|
0385f7324c | ||
|
|
7ac15e7d45 | ||
|
|
71d898c966 | ||
|
|
e062d0befd | ||
|
|
8e79bf6adf | ||
|
|
08b3312b23 | ||
|
|
0606549a7d | ||
|
|
643e6c7457 | ||
|
|
44747464da | ||
|
|
e36f04f70b | ||
|
|
9560f8747f | ||
|
|
40e2e01088 | ||
|
|
1509e04c93 | ||
|
|
d30a9b7aa9 | ||
|
|
4a54ef54cb | ||
|
|
d37c8e96d6 | ||
|
|
4855b232d8 | ||
|
|
6870774577 | ||
|
|
461da37fdf | ||
|
|
7e897bbb82 | ||
|
|
1db7f14a27 | ||
|
|
fd1c2a62f1 | ||
|
|
cceed86859 | ||
|
|
ec35400087 | ||
|
|
beb94d9d07 | ||
|
|
047a612f19 | ||
|
|
5ae02456ba | ||
|
|
6e234a1530 | ||
|
|
f5bad77a36 | ||
|
|
d386555c16 | ||
|
|
0d2d395639 | ||
|
|
03f0f7a81f | ||
|
|
f23359f3cb | ||
|
|
0cc99f0ac9 | ||
|
|
dbae83eb89 | ||
|
|
fecadb132f | ||
|
|
e5e80b693e | ||
|
|
d1699db5a6 | ||
|
|
0735039213 | ||
|
|
09abbf5d0c | ||
|
|
0732dcb816 | ||
|
|
0e6c826ac2 | ||
|
|
1a35d12609 | ||
|
|
0fa4a40869 | ||
|
|
dcf726a72a |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,7 +1,11 @@
|
||||
__pycache__
|
||||
__PYCache__
|
||||
Data
|
||||
csv_info.txt
|
||||
src/themes/.current_theme
|
||||
src/themes/__current_theme
|
||||
designer.bat
|
||||
launch.bat
|
||||
.vscode/
|
||||
.code-workspace
|
||||
spec_files/**/output
|
||||
*.txt
|
||||
*.json
|
||||
info.log
|
||||
|
||||
66
CHANGELOG.md
Normal file
66
CHANGELOG.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 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.
|
||||
|
||||
## [3.2.2] - 2022-07-29
|
||||
### Fixed
|
||||
- Fixed crash on startup or if checking for updates without an internet connection ([#23](https://github.com/AresValley/Artemis/pull/23))
|
||||
- Updated dependencies for security reasons (urllib3) and to address the main application failure to launch under certain conditions.
|
||||
|
||||
## [3.2.1] - 2020-04-25
|
||||
### Added
|
||||
- Add some basic logging to the application. Also for severe errors, track them in info.log file in local folder.
|
||||
- Add Raspberry PI support ([#18](https://github.com/AresValley/Artemis/pull/18), [#20](https://github.com/AresValley/Artemis/pull/20))
|
||||
|
||||
### Fixed
|
||||
- Support new `JSON` format for some forecast data ([#21](https://github.com/AresValley/Artemis/pull/14)).
|
||||
- Fixed categorization for very low x-ray flux according to NOAA format.
|
||||
- Remove the `exclusive` parameter in a PyQt function ([#16](https://github.com/AresValley/Artemis/pull/16)).
|
||||
|
||||
|
||||
## [3.2.0] - 2019-12-14
|
||||
|
||||
### Added
|
||||
- The default font can be changed ([#14](https://github.com/AresValley/Artemis/pull/14)).
|
||||
- Move `Themes` into `Settings`.
|
||||
- Better settings management in `settings.json`.
|
||||
|
||||
### Fixed
|
||||
- Fix a bug in the space weather. An inactive k-index caused a crash.
|
||||
|
||||
## [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.2.1...HEAD
|
||||
[3.2.1]: https://github.com/AresValley/Artemis/compare/v3.2.0...v3.2.1
|
||||
[3.2.0]: https://github.com/AresValley/Artemis/compare/v3.1.0...v3.2.0
|
||||
[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
|
||||
52
README.md
52
README.md
@@ -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 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)
|
||||
- [Syntax](#syntax)
|
||||
- [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.**
|
||||
|
||||
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 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 libraries (in `requirements/requirements.txt`)
|
||||
|
||||
@@ -49,6 +57,9 @@ pip install -r requirements.txt --user
|
||||
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
|
||||
|
||||
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 |
|
||||
| 10 | Modulation | - | ✔ | string |
|
||||
| 11 | ID Code | - | - | integer |
|
||||
| 12 | Auto-correlation function | ms | - | string |
|
||||
| 12 | Auto-correlation function | ms | ✔ | string |
|
||||
|
||||
### 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 |
|
||||
|
||||
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)
|
||||
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
|
||||
```
|
||||
Description * Modulation 1 ; Modulation 2 ; ... * ID Code
|
||||
... Description * Modulation 1 ; Modulation 2 ; ... * ID Code ...
|
||||
```
|
||||
|
||||
## Themes
|
||||
@@ -118,7 +147,7 @@ The only folder with the pre-built package is the `themes` one. In this way the
|
||||
Some of the available themes were adapted from https://github.com/GTRONICK/QSS.
|
||||
|
||||
## 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 (ARTEMIS 3, 2014-2020) 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.
|
||||
|
||||
@@ -129,6 +158,7 @@ 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*
|
||||
* **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*
|
||||
* [**Marco Bortoli**](https://github.com/marbort "GitHub profile") - *macOS deployment, β Tester*
|
||||
* [**Eric Wiessner (KI7POL)**](https://github.com/WheezyE "GitHub profile") - *ARM port (Raspberry Pi3B+ and Pi4B)*
|
||||
* [**Pierpaolo Pravatto**](https://github.com/ppravatto "GitHub profile") - *Wiki page, β Tester*
|
||||
* [**Francesco Capostagno**](https://github.com/fcapostagno "GitHub profile"), **Luca**, **Pietro** - *β Tester*
|
||||
|
||||
7
artemis-workspace.code-workspace
Normal file
7
artemis-workspace.code-workspace
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
pandas>=0.24.2
|
||||
certifi>=2019.6.16
|
||||
aiohttp>=3.5.4
|
||||
urllib3>=1.25.3
|
||||
pygame>=1.9.6
|
||||
QtAwesome>=0.5.7
|
||||
PyQt5==5.12.2
|
||||
numpy>=1.23.1
|
||||
pandas>=1.4.3
|
||||
certifi>=2022.6.15
|
||||
aiohttp>=3.8.1
|
||||
urllib3>=1.26.9
|
||||
pygame>=2.1.2
|
||||
QtAwesome>=1.1.1
|
||||
PyQt5>=5.15.7
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
# -*- mode: python -*-
|
||||
|
||||
import glob
|
||||
import os
|
||||
|
||||
|
||||
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()],
|
||||
binaries=[],
|
||||
datas=data_file,
|
||||
@@ -18,9 +27,10 @@ a = Analysis(['artemis.py'],
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False)
|
||||
pyz = PYZ(a.pure, a.zipped_data,
|
||||
pyz = PYZ(a.pure, # noqa: 821
|
||||
a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
exe = EXE(pyz,
|
||||
exe = EXE(pyz, # noqa: 821
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
49
spec_files/Linux/Artemis_onedir.spec
Normal file
49
spec_files/Linux/Artemis_onedir.spec
Normal 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
42
spec_files/Linux/build.sh
Normal 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."
|
||||
@@ -23,12 +23,12 @@ if [ -e "$file" ]; then
|
||||
read -p "" doit
|
||||
case $doit in
|
||||
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 "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 "Exec=sh -c 'cd $DIR && ./artemis' " >> /home/$USER/.local/share/applications/artemis.desktop
|
||||
echo "Terminal=False" >> /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 "Icon=artemis3" >> /home/$USER/.local/share/applications/artemis.desktop
|
||||
sudo cp ./artemis3.svg /usr/share/icons/
|
||||
echo "Link Updated!"
|
||||
@@ -41,12 +41,12 @@ if [ -e "$file" ]; then
|
||||
*) echo "Sorry! Invalid option $REPLY";;
|
||||
esac
|
||||
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 "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 "Exec=sh -c 'cd $DIR && ./artemis' " >> /home/$USER/.local/share/applications/artemis.desktop
|
||||
echo "Terminal=False" >> /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 "Icon=artemis3" >> /home/$USER/.local/share/applications/artemis.desktop
|
||||
sudo cp ./artemis3.svg /usr/share/icons/
|
||||
echo "
|
||||
|
||||
412
spec_files/Linux/raspbian_build.sh
Normal file
412
spec_files/Linux/raspbian_build.sh
Normal file
@@ -0,0 +1,412 @@
|
||||
#!/bin/bash
|
||||
|
||||
### Build Artemis for Raspberry Pi (for Raspbian Buster or Raspbian Stretch)
|
||||
# raspbian_build.sh
|
||||
# Author: Eric (KI7POL)
|
||||
# Credits: MarcoDT (testing, trouble-shooting, guidance). Keith (N7ACW) (inspiration). Jason (KM4ACK) (inspiration).
|
||||
# Version: 0.1 (May 10, 2020)
|
||||
# Description: Install pre-requisites for building Artemis on Raspberry Pi, then build Artemis from source
|
||||
|
||||
clear
|
||||
echo "======= Build Artemis for the Raspberry Pi ======="
|
||||
echo " (Raspbian Buster or Stretch)"
|
||||
echo
|
||||
echo "This script will help you build distributable Artemis executable binaries for Raspbian."
|
||||
echo
|
||||
echo "We will update your Pi, prepare a virtual environment, then make your Artemis binary."
|
||||
echo " * We will need about 1.3 GB of storage space."
|
||||
echo " * This may take anywhere from 14 hours to 45 minutes (faster Raspberry Pi's take less time)"
|
||||
echo " * PyQt5 pip module installation is the longest step, especially if it must be built from source or build its 'wheel'."
|
||||
echo
|
||||
read -n 1 -s -r -p "Press any key to continue (more instructions to follow) ..."
|
||||
clear
|
||||
|
||||
# Before building an Artemis executable binary (exe) on any Raspberry Pi computers, we need to install some system software and Artemis' pip requirements. Raspberry Pi also comes with "System Python" pre-installed, which will interfere with our building of a compact Artemis exe. This script will assist with installing system software, a clean virtual Python environment (PyEnv virtualenv), and Artemis requirements (Python pip packages), to build Artemis from. This way, Artemis will be built from a fresh virtual Python 3.7.0 installation apart from Raspbian's System Python installation.
|
||||
|
||||
# About 1.3 GB will be needed [size on disk: PyEnv 200 MB, 200 MB virtual build environment, 112 MB of temp files (PyQt5 downloads and build directory, and a 520 MB Artemis output folder after downloading the Artemis signal database]. The output folder will contain three tar.gz archives, two EXE files (Artemis 97-100 MB, depending on Buster or Stretch, and _ArtemisUpdater 49 MB), theme files, audio files, and a database file. The 'artemis' folder or tar.gz files can be distributed to end-users and run on other Raspberry Pi's. Any end-users receiving the EXE files will not need to install Artemis dependencies on their Raspberry Pi's to run the compiled EXE's. The EXE files do not seem to become smaller after running "sudo trim" commands on them.
|
||||
|
||||
# Though it is recommended to build Artemis on the oldest operating system version available to avoid 'GLIBC out of date errors' when our Artemis binary is run on newer OS's, we have found that Raspbian Stretch and Buster are sufficiently different that they require their own builds of Artemis to function without throwing error messages in the Linux terminal. Stretch and Buster also have different installation procedures. This script will detect whether you are running Buster or Stretch and then automatically build a correct Artemis binary for your Raspbian distribution.
|
||||
|
||||
# In case the PyQt5 pip module is not able to be installed on your version of Raspbian or does not work in the future, we have included the option to let the script build and install the old stable PyQt5 pip module. Just set the User-defined variable PYQTVER below to 'old'. NOTE! the "make" process for building a PyQt5 pip module from source can take over 14 hours on a Raspberry Pi 2B, over 12 hours on a Pi 0W, over 3.5 hours on a Pi 3B+, and about 2 hours on a Pi 4B-2GB. Building PyQt5 from source is only recommended if you must do it. If you can install a new (pre-built) PyQt5 module with pip and the wheel module, this script will take MUCH less time to finish (this is the default mode for this script).
|
||||
|
||||
|
||||
### User-defined variables
|
||||
PYTHVER='3.7.0' # Specify a version of Python to install into your PyEnv virtual environment (to build Artemis from).
|
||||
PYQTVER='new' # Options are 'new' and 'old'. Try forcing 'old' if you get installation errors, but old may take 2-14 more hours (the script will try to run 'old' automatically though if 'new' doesn't work)
|
||||
|
||||
### Static Variables
|
||||
TMPVAR1=$(tr -d '\0' </sys/firmware/devicetree/base/model) # Store full name of Pi
|
||||
TMPVAR2=`grep VERSION_CODENAME /etc/os-release` # Store name of Raspbian operating system version
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" # Store current location of this bash script
|
||||
BUILDENV="artemis3_python"$PYTHVER"pyqt"$PYQTVER
|
||||
RPIHARDWARE=${TMPVAR1%" Rev"*} # Extract Pi's name before " Rev", so we don't store the Pi's revision name.
|
||||
RPIOS=`cut -d "=" -f2- <<< "$TMPVAR2"` # Store 'buster', 'stretch', or something else unknown
|
||||
TSTART=`date +%s` # Log the start time of the script
|
||||
|
||||
if [[ "$RPIOS" = "stretch" ]]; then
|
||||
echo
|
||||
echo "Your operating system version is Raspbian Stretch. Since you are building Artemis on Stretch, your Artemis binary should work great on Stretch, but if you try to run it on Raspbian Buster it will throw minor font and GLIB errors in the console."
|
||||
elif [[ "$RPIOS" = "buster" ]]; then
|
||||
echo
|
||||
echo "Your operating system version is Raspbian Buster. Since you are building Artemis on Buster, your Artemis binary will work great on Buster, but will not run on Raspbian Stretch due to GLIBC out of date errors."
|
||||
else
|
||||
echo
|
||||
echo "WARNING: This script was not able to determine your operating system version (you are not running Raspbian Buster or Raspbian Stretch). We will attempt to prepare the requirements for building Artemis, but we will likely encounter errors with the preparation or build."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "For trouble-shooting, please read the comments inside this script. You can also open an issue in the Ares Valley / Artemis github or ask for assistance on the forums. Please include a link to a pastebin with the contents of your raspbian_build_debug.log file"
|
||||
|
||||
echo
|
||||
read -n 1 -s -r -p "Press any key to begin ..."
|
||||
clear
|
||||
|
||||
echo "We will now update your system."
|
||||
echo "This may take a while if you haven't updated recently."
|
||||
echo
|
||||
exec > >(tee "$DIR/raspbian_build_debug.log") 2>&1 # Make a log of this script's output
|
||||
sudo apt-get update -y
|
||||
sudo apt-get upgrade -y
|
||||
|
||||
######################################### Install PyEnv #########################################
|
||||
### Install pyenv so we can build Artemis from a fresh virtual Python (apart from Raspbian's System Python)
|
||||
|
||||
export PYENV_ROOT="$HOME/.pyenv" # Needed to help this if statement find PyEnv if it's already installed.
|
||||
export PATH="$PYENV_ROOT/bin:$PATH" # Both of these exports are also needed later in this script.
|
||||
|
||||
if hash pyenv 2>/dev/null; then
|
||||
echo "Pyenv is already installed, skipping pyenv installation..." >&2
|
||||
else
|
||||
echo "Installing pyenv now..." >&2
|
||||
|
||||
sudo apt-get install -y make build-essential libssl-dev zlib1g-dev \
|
||||
libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev \
|
||||
libncursesw5-dev xz-utils tk-dev libffi-dev liblzma-dev python-openssl
|
||||
|
||||
curl https://pyenv.run | bash
|
||||
|
||||
# Pyenv needs to be initialized whenever SHELL is loaded, so we need to add these values to .bashrc
|
||||
sudo echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
||||
sudo echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
||||
sudo echo 'eval "$(pyenv init -)"' >> ~/.bashrc
|
||||
sudo echo 'eval "$(pyenv virtualenv-init -)"' >> ~/.bashrc
|
||||
fi
|
||||
|
||||
# Initialize pyenv so this instance of SHELL can find PyEnv (so we don't have to restart SHELL).
|
||||
eval "$(pyenv init -)"
|
||||
eval "$(pyenv virtualenv-init -)"
|
||||
|
||||
|
||||
######################################### Install Python 3.7.x Inside Pyenv #########################################
|
||||
### We will install our Artemis requirements pip modules inside of this virtualenv.
|
||||
|
||||
# Check to see if Python 3.7.x is already installed in pyenv. If so, skip Python 3.7.x installation.
|
||||
if ! [ -d "/$HOME/.pyenv/versions/$PYTHVER/" ]; then
|
||||
echo "Installing Python" $PYTHVER "within Pyenv now..." >&2
|
||||
env PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install -v $PYTHVER
|
||||
else
|
||||
echo "Python" $PYTHVER "is already installed within Pyenv, skipping Python installation..." >&2
|
||||
fi
|
||||
pyenv global $PYTHVER # Make your python 3.7.0 environment the default whenever "python" is typed (instead of System python)
|
||||
|
||||
### Create our new python virtual environment using Python 3.7.x (we will activate it as soon as we need it)
|
||||
# Check to see if we've already made a Python 3.7.x virtual environment named (for example) 'artemis3_py3.7.0_pyqtnew'
|
||||
if ! [ -d "/$HOME/.pyenv/versions/$PYTHVER/envs/$BUILDENV" ]; then
|
||||
echo 'Creating a Python' $PYTHVER 'virtual environment named' $BUILDENV '...' >&2
|
||||
pyenv virtualenv $PYTHVER $BUILDENV
|
||||
else
|
||||
echo 'A virtual environment with Python' $PYTHVER 'named' $BUILDENV 'was found. We will install pip modules here...' >&2
|
||||
fi
|
||||
|
||||
|
||||
###################################### Install Artemis Requirements Into Our Pyenv ######################################
|
||||
### When building Artemis for the Raspberry Pi, we must install our Artemis requirements ourselves - without the aid of the 'requirements.txt' file in this Git (though other Linux distros might be able to still use it). This is because the Raspbian pip distro doesn't include the recommended 5.12.2 version of PyQt5, as requested by 'requirements.txt'. Therefore, if we try to install requirements using the recommended "python3.7 -m pip install -r cd ~/Artemis/requirements/requirements.txt" we will error when trying to download PyQt5 and auto-abort before we get to install ANY of the required pip modules.
|
||||
|
||||
### Note that the commands below downloads the pip modules specified in requirements.txt (including building our own PyQt5 5.12.2 from source) and then installs them within our fresh virtual python 3.7.0 environment (thereby avoiding any interference with Raspbian's default System python installation). If you try to install these modules using "sudo python3.7" or "sudo python", however, you will accidentally be installing them into System python (and not into our pristine pyenv virtualenv python 3.7.0 environment).
|
||||
|
||||
pyenv activate $BUILDENV # Activate the new Python 3.7.x virtualenv so we can start installing python pip modules in it
|
||||
|
||||
sudo apt-get install libatlas-base-dev -y # Needed for numpy (on Buster?)
|
||||
sudo apt-get install libsdl-image1.2-dev libsdl-mixer1.2-dev libsdl-ttf2.0-dev libsdl1.2-dev libsmpeg-dev subversion libportmidi-dev ffmpeg libswscale-dev libavformat-dev libavcodec-dev -y # Needed for pygame on Raspbian Stretch? (not needed for Buster)
|
||||
sudo apt-get install qt5-default -y # Ensure Qt5 is installed (doesn't come pre-install on Buster for Pi 0-2)
|
||||
|
||||
python3.7 -m pip install --upgrade pip wheel # Necessary for Stretch installations of pandas and numpy
|
||||
if [[ "$RPIOS" = "stretch" ]]; then
|
||||
python3.7 -m pip install --upgrade 'setuptools==44.0.0' # This specific version needed for NumPy on Stretch
|
||||
python3.7 -m pip install 'numpy==1.17.2' --no-cache-dir --no-binary :all: # Needed for Stretch but takes longer
|
||||
else
|
||||
python3.7 -m pip install 'numpy==1.17.2' # Works on Buster
|
||||
fi
|
||||
python3.7 -m pip install 'pandas>=0.24.2'
|
||||
python3.7 -m pip install 'certifi>=2019.6.16'
|
||||
python3.7 -m pip install 'aiohttp>=3.5.4'
|
||||
python3.7 -m pip install 'urllib3==1.24.3'
|
||||
python3.7 -m pip install 'pygame>=1.9.6'
|
||||
python3.7 -m pip install 'QtAwesome>=0.6.0'
|
||||
if [[ "$PYQTVER" = "new" ]]; then
|
||||
python3.7 -m pip install PyQt5-sip
|
||||
echo
|
||||
echo "WARNING: If PyQt5 needs to build its own wheel from scratch on your system then this step may take anywhere from 2-14 hours (depending on your Raspberry Pi's hardware)."
|
||||
echo
|
||||
python3.7 -m pip install PyQt5
|
||||
fi
|
||||
|
||||
# Find out if PyQt5 needs to be installed (useful if we already installed it in a previous run of this script, or if the newer PyQt5 version failed to install)
|
||||
pip list | grep 'PyQt5 ' &> /dev/null
|
||||
if [ $? == 0 ]; then
|
||||
echo "PyQt5 is already installed in this Pyenv."
|
||||
else
|
||||
echo "Downloading and installing PyQt5 and sip into your Pyenv virtual environment"
|
||||
|
||||
mkdir $DIR/Downloads
|
||||
cd $DIR/Downloads
|
||||
wget https://www.riverbankcomputing.com/static/Downloads/sip/4.19.17/sip-4.19.17.tar.gz # Old PyQt5 requires sip
|
||||
wget https://www.riverbankcomputing.com/static/Downloads/PyQt5/5.12.2/PyQt5_gpl-5.12.2.tar.gz
|
||||
tar xvfz sip-4.19.17.tar.gz # I chose sip 4.19.17 since it was released the same day as PyQt5 5.12.2.
|
||||
tar xvfz PyQt5_gpl-5.12.2.tar.gz
|
||||
|
||||
cd $DIR/Downloads
|
||||
cd sip-4.19.17
|
||||
python configure.py --sip-module PyQt5.sip # Pip will list the sip module as "PyQt5-sip", module file is "sip.*so"
|
||||
if [[ "$RPIHARDWARE" = "Raspberry Pi 3 Model A+" ]] || [[ "$RPIHARDWARE" = "Raspberry Pi 2 Model B" ]] || [[ "$RPIHARDWARE" = "Raspberry Pi 3 Model B" ]] || [[ "$RPIHARDWARE" = "Raspberry Pi 3 Model B+" ]] || [[ "$RPIHARDWARE" = "Raspberry Pi 4 Model B" ]]; then # Only run -j4 on Raspberry Pi computers with four cores
|
||||
make -j4
|
||||
else
|
||||
make
|
||||
fi
|
||||
make install
|
||||
|
||||
cd $DIR/Downloads
|
||||
cd PyQt5_gpl-5.12.2
|
||||
python configure.py --confirm-license --sip-incdir="$DIR/Downloads/sip-4.19.17/siplib"
|
||||
if [[ "$RPIHARDWARE" = "Raspberry Pi 3 Model A+" ]] || [[ "$RPIHARDWARE" = "Raspberry Pi 2 Model B" ]] || [[ "$RPIHARDWARE" = "Raspberry Pi 3 Model B" ]] || [[ "$RPIHARDWARE" = "Raspberry Pi 3 Model B+" ]] || [[ "$RPIHARDWARE" = "Raspberry Pi 4 Model B" ]]; then # Only run -j4 on Raspberry Pi computers with four cores
|
||||
echo
|
||||
echo "WARNING: This step may take anywhere from 1-14 hours, depending on your Raspberry Pi's hardware."
|
||||
echo
|
||||
make -j4
|
||||
else
|
||||
make
|
||||
fi
|
||||
make install
|
||||
fi
|
||||
|
||||
# Stretch needs Pyinstaller 3.4 to build Artemis correctly (otherwise sip errors on run). Buster isn't picky.
|
||||
if [[ "$RPIOS" = "stretch" ]]; then
|
||||
cd ~/Downloads
|
||||
wget https://github.com/pyinstaller/pyinstaller/releases/download/v3.4/PyInstaller-3.4.tar.gz
|
||||
tar -xvf PyInstaller-3.4.tar.gz
|
||||
cd PyInstaller-3.4/bootloader/
|
||||
sudo python ./waf configure --no-lsb all
|
||||
cd ..
|
||||
python3.7 setup.py install
|
||||
else
|
||||
python3.7 -m pip install pyinstaller
|
||||
fi
|
||||
|
||||
eval "$(pyenv init -)" # We need to remind SHELL that we just installed pyinstaller exe into our pyenv
|
||||
eval "$(pyenv virtualenv-init -)"
|
||||
|
||||
######################################### Build Artemis From Our Pyenv #########################################
|
||||
|
||||
cd $DIR # Change the shell's working directory to the same location as our .spec files. This is a critical step before compiling! (This should put us into ...'/Artemis/spec_files/Linux/', where 'build.sh' and 'Artemis.spec' are located.)
|
||||
|
||||
sudo chmod +x build.sh
|
||||
./build.sh # The build script builds two Artemis exe's, copies the themes and shortcut-maker files into the output directory, packages everything, and then runs hashes.
|
||||
|
||||
sudo chmod -R 755 output/artemis
|
||||
sudo rm output/Artemis output/_ArtemisUpdater # We'll just remove these redundant output folders so users don't get confused
|
||||
|
||||
TEND=`date +%s` # Log the end time of the script
|
||||
TTOTAL=$((TEND-TSTART))
|
||||
echo '(Script completed in' $TTOTAL 'seconds)' # Report how long it took to install requirements and build Artemis
|
||||
|
||||
|
||||
######################################### Clean Up #########################################
|
||||
|
||||
pyenv deactivate # The virtualenv may also be deactivated when we close the terminal window
|
||||
pyenv global system # Set the active Python version back to System Python (instead of PyEnv Python 3.7.x)
|
||||
|
||||
echo
|
||||
read -p "Would you like to remove the build pre-requisites we installed? (y/n) `echo $'\n '`(Removing these files will free up about 200 MB, but keeping the files will make re-running this script take much less time. We will not delete PyEnv which is another 200 MB, but you can delete its folder manually to remove it if you like.) `echo $'\n> '`" REMOVEFILES
|
||||
if [ $REMOVEFILES = "y" ] || [ $REMOVEFILES = "Y" ]; then
|
||||
sudo rm -rf /home/pi/.pyenv/versions/$BUILDENV
|
||||
sudo rm -rf /home/pi/.pyenv/versions/$PYTHVER/envs/$BUILDENV
|
||||
fi
|
||||
|
||||
######################################### Notes #########################################
|
||||
|
||||
### Run Artemis from a shortcut, from File Manager, or with the './Artemis' command in the terminal. Follow the on-screen prompts to download the database and audio files.
|
||||
|
||||
### Other Resources
|
||||
# Python Virtual Environments https://www.youtube.com/watch?v=N5vscPTWKOk
|
||||
# Python VirtEnv reduces built EXE file sizes https://stackoverflow.com/questions/47692213/reducing-size-of-pyinstaller-exe
|
||||
# Setuptools error: "ModuleNotFoundError: No module named 'pkg_resources.py2_warn'" https://github.com/pypa/setuptools/issues/1963
|
||||
# Trouble-shooting pyinstaller https://github.com/pyinstaller/pyinstaller/wiki/How-to-Report-Bugs#make-sure-everything-is-packaged-correctly
|
||||
# Using pyinstaller with venv: https://pyinstaller.readthedocs.io/en/stable/development/venv.html
|
||||
# Learning Pyenv https://realpython.com/intro-to-pyenv/
|
||||
# Search Debian apt-get repo for missing packages https://www.debian.org/distrib/packages#search_contents
|
||||
# https://www.cyberciti.biz/faq/howto-check-if-a-directory-exists-in-a-bash-shellscript/
|
||||
# https://pyinstaller.readthedocs.io/en/stable/development/venv.html
|
||||
# https://stackoverflow.com/questions/16931244/checking-if-output-of-a-command-contains-a-certain-string-in-a-shell-script
|
||||
# PyQt5 build instructions: https://www.riverbankcomputing.com/static/Docs/PyQt5/building_with_configure.html
|
||||
|
||||
### Future work
|
||||
# See if PyQt5 install can be sped up with --no-tools and other conditions.
|
||||
#
|
||||
|
||||
### Approximate installation times:
|
||||
# RPi 4B (2GB) ???. "Raspberry Pi 4 Model B Rev 1.2"
|
||||
# RPi 3B+ 3.5 hours. "Raspberry Pi 3 Model B Plus Rev 1.3"
|
||||
# RPi 3B ??? hours. "Raspberry Pi 3 Model B Rev 1.2"
|
||||
# Rpi 2B 12? hours.
|
||||
# Rpi 0W 12 hours. "Raspberry Pi Zero W Rev 1.1"
|
||||
# Find model using 'cat /sys/firmware/devicetree/base/model'
|
||||
|
||||
### Other commands
|
||||
#python -V # You can test which version of python has priority now on your system if you like
|
||||
#python -m test # You can run python diagnostics if you want to check the integrity of your new python. My test for Python 3.7.0 took 56 minutes on the Pi3B+ and resulted in a "== Tests result: FAILURE == ... 6 tests failed: test_asyncio test_ftplib test_imaplib test_nntplib test_poplib test_ssl" but my Artemis 3.2.0 build still worked.
|
||||
|
||||
|
||||
##################################### Error Messages and Workarounds ##############################
|
||||
|
||||
### NOTE!!! Artemis must be built by running "pyinstaller Artemis.spec" WHILE THE TERMINAL'S WORKING DIRECTORY IS INSIDE THE "/Artemis/spec_files/Linux" folder! If you try to build the Artemis.spec while not in the working directory (for example, by being inside "~/Downloads/ArtemisTestBuild/" and running "PyInstaller ~/Downloads/Artemis/spec_files/Linux/Artemis.spec") then Artemis will build ok but then crash when you try to run the Artemis executable: "FileNotFoundError: [Errno 2] No such file or directory: '/tmp/_MEIaHNect/download_db_window.ui'". Said another way, you MUST cd into the "Artemis/spec_files/Linux" directory BEFORE running the "pyinstaller Artemis.spec" command!
|
||||
|
||||
### If you get: "[1449] Error loading Python lib '/tmp/_MEIATNM1X/libpython3.7m.so.1.0': dlopen: /lib/arm-linux-gnueabihf/libc.so.6: version `GLIBC_2.28' not found (required by /tmp/_MEIATNM1X/libpython3.7m.so.1.0)" Try updating GLIBC with "sudo apt-get install libc6". If this doesn't work, this error might be due to your running a pre-compiled Artemis binary that was built on a newer Linux OS than the one you are running. If this is the case, then use this build script to build Artemis for your system.
|
||||
|
||||
### If you get errors installing newer PyQt5 modules with pip or troubles compiling Artemis like: "ModuleNotFoundError: No module named 'pkg_resources.py2_warn'", you might try upgrading pip, installing a different version of pip setuptools (newer or older), and then reinstalling pyinstaller. That error may show up for setuptools newer than 44.0.0. Therefore, try: "python3.7 -m pip install --upgrade 'setuptools==44.0.0'"
|
||||
|
||||
### If you get the error "bash: PyInstaller: command not found" when trying to build Artemis, then run the commands below (or reload shell or just close and re-open the terminal). After that, ensure pyenv is also reinitialized by activating your virtual env again.
|
||||
#eval "$(pyenv init -)"
|
||||
#eval "$(pyenv virtualenv-init -)"
|
||||
#pyenv activate YOUR_BUILD_ENVIRONMENT_HERE
|
||||
|
||||
### We need to install python inside of pyenv with '--enable-shared' or else we will get this when trying to build Artemis on Stretch "OSError: Python library not found: libpython3.7m.so, libpython3.7mu.so.1.0, libpython3.7.so.1.0, libpython3.7m.so.1.0"
|
||||
|
||||
### If you get "libf77blas.so.3: cannot open shared object file: No such file or directory" during Artemis compile, make sure 'libatlas-base-dev' was installed with apt-get (this error is caused by NumPy).
|
||||
|
||||
### NumPy 1.17.2 errors on Stretch if it is not installed with "--no-cache-dir --no-binary :all:". "Original error was: /lib/arm-linux-gnueabihf/libm.so.6: version `GLIBC_2.27' not found (required by /home/pi/.pyenv/versions/artemis3_py370_pyqt5122/lib/python3.7/site-packages/numpy/core/_multiarray_umath.cpython-37m-arm-linux-gnueabihf.so)"
|
||||
|
||||
### If we installed our pip modules inside of System Python (ie we didn't run 'pyenv activate YOUR_VIRTUAL_ENV_NAME_HERE'), then we would end up with a final Artemis file that was 1.3GB and which would error when trying to copy itself from build/PKG-00.pkg into its final dist/Artemis EXE, with error message "raise SystemError("objcopy Failure: %s" % stderr) SystemError: objcopy Failure: objcopy: out of memory allocating 536870912 bytes after a total of 0 bytes". However, since we're building a fresh Python environment to install ONLY our needed Artemis modules inside of, we will not get this error and we will build an Artemis EXE that is about 97.1 MB.
|
||||
|
||||
### On Raspbian Stretch (not Buster), any Pyinstaller version >= 3.5 will cause PyQt5.sip (sip.so) to be incorrectly installed into the main directory within the Artemis exe (and not into the PyQt5 folder within the exe, where sip should be), so that Artemis will build without errors but will error when run ("ModuleNotFoundError: No module named 'PyQt5.sip'"). Therefore, PyInstaller 3.4 should be used on Raspbian Stretch (earlier PyInstaller versions don't install correctly on Stretch anyway). This error might be fixable on Stretch with a custom-written PyInstaller hook for sip if you need to use a newer PyInstaller version on Raspbian Stretch, but I haven't tested this. Raspbian Buster does not have this problem with PyInstaller and sip for some reason, so that any PyInstaller version should be fine.
|
||||
|
||||
### If we try to install PyQt5 (using pip or by building from source) without having Qt5 installed on our system, then we will get errors related to qmake: “Error: Use the --qmake argument to explicitly specify a working Qt qmake. qmake not compatible for building PyQt5” Qt5 doesn't come pre-installed on Raspbian Buster for Raspberry Pi 0-2 (but does on newer Pi's running Raspbian Buster). Install it with "sudo apt-get install qt5-default"
|
||||
|
||||
### If you get build errors with Artemis on Raspbian Buster, you might consider a different PyInstaller version built from source - Either the newest development version ("python3.7 -m pip install https://github.com/pyinstaller/pyinstaller/archive/develop.zip"), or an older version ("python3.7 -m pip install --upgrade 'PyInstaller==3.5'").
|
||||
|
||||
### If you type ./Artemis in the terminal and Artemis runs, but looks all grey when it runs and gives you a "Missing theme folder" dialog box error, then make sure that, when you are running the ./Artemis command, your terminal's working directory is the same directory that houses the Artemis executable file: For example, while terminal is in the "/opt/" directory, if you run "Artemis/./Artemis" then you'll get the theme folder error, but if you cd to the "/opt/Artemis/" directory and run "./Artemis", then this may fix the theme folder error.
|
||||
|
||||
### Similar to the theme folder error, if you run Artemis from terminal while your working directory is outside the folder that houses the Artemis executable file, then downloading the database may create a "Data" folder outside of the folder where the Artemis executable is. Just close Artemis, move the Data folder into the Artemis folder, run the Artemis exe again from a shortcut or from File Manager, or from terminal (but in the correct working directory) and everything should work.
|
||||
|
||||
### If you get this error: "QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-root'", log out of your Pi then log back in. This may be a problem with the EXE getting confused by pyenv if pyenv is still active when the EXE is first run. Pyenv activates automatically by being inside the ~/Environments/artemis3_py370_pyqt5122/ directory.
|
||||
|
||||
|
||||
################################# Working installations and console outputs ########################
|
||||
### My Raspberry Pi 3B+ build specs (Raspbian Buster)
|
||||
#python3.7 -O -m PyInstaller --clean Artemis.spec
|
||||
|
||||
#323 INFO: PyInstaller: 3.5 # Also successfully tested with Pyinstaller 3.6
|
||||
#323 INFO: Python: 3.7.0
|
||||
#328 INFO: Platform: Linux-4.19.97-v7+-armv7l-with-debian-10.3
|
||||
#335 INFO: UPX is not available.
|
||||
|
||||
|
||||
#pip list
|
||||
|
||||
#Package Version
|
||||
#--------------- ----------
|
||||
#aiohttp 3.6.2
|
||||
#altgraph 0.17
|
||||
#async-timeout 3.0.1
|
||||
#attrs 19.3.0
|
||||
#certifi 2020.4.5.1
|
||||
#chardet 3.0.4
|
||||
#idna 2.9
|
||||
#multidict 4.7.5
|
||||
#numpy 1.17.2
|
||||
#pandas 1.0.3
|
||||
#pip 10.0.1
|
||||
#pygame 1.9.6
|
||||
#PyInstaller 3.6
|
||||
#PyQt5 5.12.2
|
||||
#PyQt5-sip 4.19.17
|
||||
#python-dateutil 2.8.1
|
||||
#pytz 2019.3
|
||||
#QtAwesome 0.7.0
|
||||
#QtPy 1.9.0
|
||||
#setuptools 39.0.1
|
||||
#six 1.14.0
|
||||
#urllib3 1.24.3
|
||||
#yarl 1.4.2
|
||||
|
||||
|
||||
### Terminal output on RPi3B+ (Buster) for our build
|
||||
#spec_files/Linux $ dist/./Artemis
|
||||
#pygame 1.9.6
|
||||
#Hello from the pygame community. https://www.pygame.org/contribute.html
|
||||
#libEGL warning: DRI2: failed to authenticate
|
||||
#qt5ct: using qt5ct plugin
|
||||
#qt5ct: D-Bus global menu: no
|
||||
#[Artemis then launches]
|
||||
|
||||
|
||||
|
||||
### Terminal output on RPi3B (not plus) (Stretch) for our build
|
||||
#(artemis3_pyqt5122) pi@raspberrypi:~/Downloads/Artemis/spec_files/Linux/dist $ ./Artemis
|
||||
#pygame 1.9.6
|
||||
#Hello from the pygame community. https://www.pygame.org/contribute.html
|
||||
#libEGL warning: DRI2: failed to authenticate
|
||||
#[Artemis then launches]
|
||||
|
||||
#NOTE: This RPi3B Stretch build will run on Buster, but only will display fonts correctly if we rename our Buster /etc/fonts/ directory, copy the Stretch fonts directory into Buster, and run ./Artemis as sudo. I am investigating whether this may be due to old PyInstaller installing weirdly.
|
||||
#[on stretch] sudo cp -L -r /etc/fonts /media/pi/ENFAIN/fonts_stretch
|
||||
#[on buster] sudo mv /etc/fonts /etc/fonts.bak
|
||||
#[on buster] sudo cp -L -r /media/pi/ENFAIN/fonts_stretch /etc/fonts
|
||||
#https://askubuntu.com/questions/1098809/ubuntu-18-10-fontconfig
|
||||
|
||||
#(artemis3_pyqt5122) pi@raspberrypi:~/Downloads/Artemis/spec_files/Linux/dist $ pip list
|
||||
#Package Version
|
||||
#--------------- ----------
|
||||
#aiohttp 3.6.2
|
||||
#altgraph 0.17
|
||||
#async-timeout 3.0.1
|
||||
#attrs 19.3.0
|
||||
#certifi 2020.4.5.1
|
||||
#chardet 3.0.4
|
||||
#future 0.18.2
|
||||
#idna 2.9
|
||||
#macholib 1.14
|
||||
#multidict 4.7.5
|
||||
#numpy 1.17.2
|
||||
#pandas 1.0.3
|
||||
#pefile 2019.4.18
|
||||
#pip 20.1
|
||||
#pygame 1.9.6
|
||||
#PyInstaller 3.4
|
||||
#PyQt5 5.12.2
|
||||
#PyQt5-sip 4.19.17
|
||||
#python-dateutil 2.8.1
|
||||
#pytz 2020.1
|
||||
#QtAwesome 0.7.1
|
||||
#QtPy 1.9.0
|
||||
#setuptools 44.0.0
|
||||
#six 1.14.0
|
||||
#urllib3 1.24.3
|
||||
#wheel 0.34.2
|
||||
#yarl 1.4.2
|
||||
|
||||
|
||||
### Terminal output on RPi4B for our build
|
||||
#pi@raspberrypi:~/Desktop/Artemis3 Pi3B+ $ ./Artemis
|
||||
#pygame 1.9.6
|
||||
#Hello from the pygame community. https://www.pygame.org/contribute.html
|
||||
#qt5ct: using qt5ct plugin
|
||||
#qt5ct: D-Bus global menu: no
|
||||
#[Artemis then launches]
|
||||
|
||||
|
||||
### Terminal output on RPi4B for the official Linux build
|
||||
#pi@raspberrypi:~/Downloads/artemis $ ./Artemis
|
||||
#bash: ./Artemis: cannot execute binary file: Exec format error
|
||||
#pi@raspberrypi:~/Downloads/artemis $ ./_ArtemisUpdater
|
||||
#bash: ./_ArtemisUpdater: cannot execute binary file: Exec format error
|
||||
#[Artemis doesn't launch]
|
||||
51
spec_files/Linux/updater.spec
Normal file
51
spec_files/Linux/updater.spec
Normal 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')
|
||||
@@ -6,34 +6,59 @@
|
||||
|
||||
## 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
|
||||
- Python 3.7.0+
|
||||
- Pyinstaller
|
||||
|
||||
## Package Building
|
||||
**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.
|
||||
2. Choose the target OS in `spec_files` folder and copy the whole content into `src`
|
||||
3. Open a terminal into `src` and run:
|
||||
**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 (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)
|
||||
|
||||
**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
|
||||
```
|
||||
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
|
||||
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.
|
||||
## Package Building (one-dir, shorter startup time, low portability)
|
||||
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
|
||||
* **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*
|
||||
## Build scripts (Windows, Linux, macOS)
|
||||
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:
|
||||
|
||||
- Executable versions of Artemis and the updater;
|
||||
- compressed versions of the same files;
|
||||
- a folder called `Artemis/` containing the executables and the `theme` folder
|
||||
- a compressed version of the folder
|
||||
|
||||
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`.
|
||||
|
||||
## Build script (Raspberry Pi)
|
||||
Thanks to [**Eric Wiessner (KI7POL)**](https://github.com/WheezyE "GitHub profile"), an automatic script to compile Artemis (and meet all the Artemis' build requirements) is available in the Linux folder `Linux/raspbian_build.sh`. The script will proceed as follows:
|
||||
|
||||
- Detect which Raspbian operating system is being used (Buster or Stretch), installing PyEnv on the system (if PyEnv isn't already installed).
|
||||
- Installation of a Python v3.7.0 virtual environment inside of PyEnv (so that Artemis' pip modules do not conflict with Raspbian's System Python).
|
||||
- Installation of pip modules (Artemis requirements) within the previously built virtual Python 3.7.0 (modules are specific to Buster or Stretch).
|
||||
- Canonical Artemis building for Linux OS (using build.sh).
|
||||
|
||||
**The complete instructions, a troubleshooting guide and many other usefull details are available in the script itself.**
|
||||
|
||||
If the script is re-run, it will skip over parts it has already installed. Options are included to clean up after the script is run. In the worst case scenario, this script takes 14 hours on a Pi 0W, but much less time on multi-core Pi's.
|
||||
|
||||
50
spec_files/Windows/Artemis_onedir.spec
Normal file
50
spec_files/Windows/Artemis_onedir.spec
Normal 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')
|
||||
@@ -1,12 +1,21 @@
|
||||
# -*- mode: python -*-
|
||||
|
||||
import glob
|
||||
import os
|
||||
|
||||
|
||||
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()],
|
||||
binaries=[],
|
||||
datas=data_file,
|
||||
@@ -18,9 +27,10 @@ a = Analysis(['artemis.py'],
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False)
|
||||
pyz = PYZ(a.pure, a.zipped_data,
|
||||
pyz = PYZ(a.pure, # noqa: 821
|
||||
a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
exe = EXE(pyz,
|
||||
exe = EXE(pyz, # noqa: 821
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
@@ -32,4 +42,6 @@ exe = EXE(pyz,
|
||||
strip=False,
|
||||
upx=True,
|
||||
runtime_tmpdir=None,
|
||||
console=False , icon='Artemis3.ico')
|
||||
console=False,
|
||||
icon='Artemis3.ico',
|
||||
uac_admin=True)
|
||||
|
||||
30
spec_files/Windows/build.bat
Normal file
30
spec_files/Windows/build.bat
Normal 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.
|
||||
1
spec_files/Windows/excluded_files.txt
Normal file
1
spec_files/Windows/excluded_files.txt
Normal file
@@ -0,0 +1 @@
|
||||
__current_theme
|
||||
52
spec_files/Windows/updater.spec
Normal file
52
spec_files/Windows/updater.spec
Normal 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)
|
||||
31
spec_files/__get_hash_code.py
Normal file
31
spec_files/__get_hash_code.py
Normal 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)
|
||||
@@ -1,12 +1,21 @@
|
||||
# -*- mode: python -*-
|
||||
|
||||
import glob
|
||||
import os
|
||||
|
||||
|
||||
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()],
|
||||
binaries=[],
|
||||
datas=data_file,
|
||||
@@ -18,9 +27,10 @@ a = Analysis(['artemis.py'],
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False)
|
||||
pyz = PYZ(a.pure, a.zipped_data,
|
||||
pyz = PYZ(a.pure, # noqa: 821
|
||||
a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
exe = EXE(pyz,
|
||||
exe = EXE(pyz, # noqa: 821
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
@@ -30,14 +40,14 @@ exe = EXE(pyz,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=False)
|
||||
coll = COLLECT(exe,
|
||||
coll = COLLECT(exe, # noqa: 821
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
name='Artemis')
|
||||
app = BUNDLE(coll,
|
||||
app = BUNDLE(coll, # noqa: 821
|
||||
name='Artemis.app',
|
||||
icon='Artemis3.icns',
|
||||
bundle_identifier=None)
|
||||
|
||||
55
src/acfvalue.py
Normal file
55
src/acfvalue.py
Normal file
@@ -0,0 +1,55 @@
|
||||
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
|
||||
if self._value.isdigit():
|
||||
self.numeric_value = float(self._value)
|
||||
self.is_numeric = True
|
||||
self._string += " ms"
|
||||
else:
|
||||
self.is_numeric = False
|
||||
self.numeric_value = 0.0
|
||||
|
||||
@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)
|
||||
1250
src/artemis.py
1250
src/artemis.py
File diff suppressed because it is too large
Load Diff
2168
src/artemis.ui
2168
src/artemis.ui
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,15 @@ import os
|
||||
from pygame import mixer
|
||||
from PyQt5.QtCore import QTimer, pyqtSlot, QObject
|
||||
|
||||
from constants import Constants
|
||||
import qtawesome as qta
|
||||
from constants import Constants
|
||||
|
||||
|
||||
class AudioPlayer(QObject):
|
||||
"""Subclass QObject. Audio player widget for the audio samples.
|
||||
|
||||
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."""
|
||||
|
||||
_TIME_STEP = 500 # Milliseconds.
|
||||
@@ -19,17 +19,21 @@ class AudioPlayer(QObject):
|
||||
pause,
|
||||
stop,
|
||||
volume,
|
||||
loop,
|
||||
audio_progress,
|
||||
active_color,
|
||||
inactive_color):
|
||||
"""Initialize the player."""
|
||||
super().__init__()
|
||||
self._active_color = active_color
|
||||
self._inactive_color = inactive_color
|
||||
self._paused = False
|
||||
self._first_call = True
|
||||
self._play = play
|
||||
self._pause = pause
|
||||
self._stop = stop
|
||||
self._volume = volume
|
||||
self._loop = loop
|
||||
self._audio_progress = audio_progress
|
||||
self._audio_file = None
|
||||
self._timer = QTimer()
|
||||
@@ -38,22 +42,45 @@ class AudioPlayer(QObject):
|
||||
self._pause.clicked.connect(self._pause_audio)
|
||||
self._stop.clicked.connect(self._stop_audio)
|
||||
self._volume.valueChanged.connect(self._set_volume)
|
||||
self._loop.clicked.connect(self._set_loop_icon)
|
||||
self._play.setIconSize(self._play.size())
|
||||
self._pause.setIconSize(self._pause.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."""
|
||||
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_disabled=inactive_color))
|
||||
self._pause.setIcon(qta.icon('fa5.pause-circle',
|
||||
self._pause.setIcon(qta.icon('fa5s.pause',
|
||||
color=active_color,
|
||||
color_disabled=inactive_color))
|
||||
self._stop.setIcon(qta.icon('fa5.stop-circle',
|
||||
self._stop.setIcon(qta.icon('fa5s.stop',
|
||||
color=active_color,
|
||||
color_disabled=inactive_color))
|
||||
self._set_loop_icon()
|
||||
|
||||
@pyqtSlot()
|
||||
def _set_volume(self):
|
||||
@@ -65,6 +92,8 @@ class AudioPlayer(QObject):
|
||||
|
||||
def _reset_audio_widget(self):
|
||||
"""Reset the widget. Stop all playing samples."""
|
||||
self._first_call = True
|
||||
self._paused = False
|
||||
if mixer.get_init():
|
||||
if mixer.music.get_busy():
|
||||
mixer.music.stop()
|
||||
@@ -72,7 +101,6 @@ class AudioPlayer(QObject):
|
||||
mixer.quit()
|
||||
self._audio_progress.reset()
|
||||
self._enable_buttons(False, False, False)
|
||||
self._paused = False
|
||||
|
||||
@pyqtSlot()
|
||||
def _update_bar(self):
|
||||
@@ -81,6 +109,10 @@ class AudioPlayer(QObject):
|
||||
if pos == -1:
|
||||
self._timer.stop()
|
||||
self._audio_progress.reset()
|
||||
if self._loop.isChecked():
|
||||
self._play_audio()
|
||||
self._enable_buttons(False, True, True)
|
||||
else:
|
||||
self._enable_buttons(True, False, False)
|
||||
else:
|
||||
self._audio_progress.setValue(pos)
|
||||
@@ -93,10 +125,8 @@ class AudioPlayer(QObject):
|
||||
|
||||
def set_audio_player(self, fname=""):
|
||||
"""Set the current audio sample."""
|
||||
self._first_call = True
|
||||
self._reset_audio_widget()
|
||||
full_name = os.path.join(
|
||||
Constants.DATA_FOLDER,
|
||||
Constants.AUDIO_FOLDER,
|
||||
fname + '.ogg'
|
||||
)
|
||||
|
||||
4619
src/cacert.pem
Normal file
4619
src/cacert.pem
Normal file
File diff suppressed because it is too large
Load Diff
130
src/constants.py
130
src/constants.py
@@ -1,13 +1,22 @@
|
||||
from collections import namedtuple
|
||||
from enum import Enum, auto
|
||||
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"
|
||||
RASPBIAN = "raspberry"
|
||||
|
||||
|
||||
class Ftype:
|
||||
"""Container class to differentiate between frequency and band.
|
||||
|
||||
Used in reset_fb_filters.
|
||||
"""
|
||||
"""Container class to differentiate between frequency and band."""
|
||||
|
||||
FREQ = "freq"
|
||||
BAND = "band"
|
||||
@@ -20,30 +29,13 @@ class GfdType(Enum):
|
||||
LOC = auto()
|
||||
|
||||
|
||||
class ChecksumWhat(Enum):
|
||||
"""Enum class to distinguish the object you want to verify the checksum."""
|
||||
class DownloadTarget(Enum):
|
||||
"""Enum class to distinguish the object being downloaded."""
|
||||
|
||||
FOLDER = auto()
|
||||
DATA_FOLDER = auto()
|
||||
DB = 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."
|
||||
SOFTWARE = auto()
|
||||
UPDATER = auto()
|
||||
|
||||
|
||||
class Signal:
|
||||
@@ -88,7 +80,8 @@ class Database:
|
||||
Signal.MODE,
|
||||
Signal.INF_BAND,
|
||||
Signal.SUP_BAND,
|
||||
Signal.CATEGORY_CODE)
|
||||
Signal.CATEGORY_CODE,
|
||||
Signal.ACF,)
|
||||
|
||||
|
||||
class ForecastColors:
|
||||
@@ -108,36 +101,37 @@ _Band = namedtuple("Band", ["lower", "upper"])
|
||||
class Constants:
|
||||
"""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"
|
||||
VERSION_LINK = "https://aresvalley.com/Storage/Artemis/Package/latest_versions.json"
|
||||
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"
|
||||
FORUM_LINK = "https://aresvalley.com/community/"
|
||||
ARESVALLEY_LINK = "https://aresvalley.com/"
|
||||
GITHUB_REPO = "https://github.com/AresValley/Artemis"
|
||||
RTL_SDL_LINK = "https://www.rtl-sdr.com/"
|
||||
UPDATING_STR = "Updating..."
|
||||
ACF_DOCS = "https://aresvalley.com/documentation/"
|
||||
FORECAST_PROBABILITIES = "https://services.swpc.noaa.gov/text/sgarf.txt"
|
||||
SPACE_WEATHER_XRAY = "https://services.swpc.noaa.gov/text/goes-xray-flux-primary.txt"
|
||||
SPACE_WEATHER_PROT_EL = "https://services.swpc.noaa.gov/text/goes-particle-flux-primary.txt"
|
||||
SPACE_WEATHER_XRAY = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json"
|
||||
SPACE_WEATHER_PROT_EL = "https://services.swpc.noaa.gov/json/goes/primary/integral-protons-1-day.json"
|
||||
SPACE_WEATHER_AK_INDEX = "https://services.swpc.noaa.gov/text/wwv.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_INFO = "https://www.swpc.noaa.gov/sites/default/files/images/NOAAscales.pdf"
|
||||
SPACE_WEATHER_IMGS = ["http://www.mmmonvhf.de/eme/eme.png",
|
||||
"http://www.mmmonvhf.de/ms/ms.png",
|
||||
"http://www.mmmonvhf.de/es/es.png",
|
||||
"http://www.mmmonvhf.de/solar/solar.png",
|
||||
"http://amunters.home.xs4all.nl/eskip50status.gif",
|
||||
"http://amunters.home.xs4all.nl/eskip70status.gif",
|
||||
"http://amunters.home.xs4all.nl/eskipstatus.gif",
|
||||
SPACE_WEATHER_IMGS = ["https://www.mmmonvhf.de/eme/eme.png",
|
||||
"https://www.mmmonvhf.de/ms/ms.png",
|
||||
"https://www.mmmonvhf.de/es/es.png",
|
||||
"https://www.mmmonvhf.de/solar/solar.png",
|
||||
"https://amunters.home.xs4all.nl/eskip50status.gif",
|
||||
"https://amunters.home.xs4all.nl/eskip70status.gif",
|
||||
"https://amunters.home.xs4all.nl/eskipstatus.gif",
|
||||
"https://amunters.home.xs4all.nl/eskipstatusNA.gif",
|
||||
"https://amunters.home.xs4all.nl/aurorastatus.gif"]
|
||||
SEARCH_LABEL_IMG = "search_icon.png"
|
||||
VOLUME_LABEL_IMG = "volume.png"
|
||||
DATA_FOLDER = "Data"
|
||||
SPECTRA_FOLDER = "Spectra"
|
||||
SPECTRA_EXT = ".png"
|
||||
AUDIO_FOLDER = "Audio"
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
LABEL_ON_COLOR = "on"
|
||||
@@ -181,6 +175,64 @@ class Constants:
|
||||
NOT_AVAILABLE = "spectrumnotavailable.png"
|
||||
NOT_SELECTED = "nosignalselected.png"
|
||||
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_NOT_SELECTED = os.path.join(DEFAULT_IMGS_FOLDER, NOT_SELECTED)
|
||||
DEFAULT_NOT_AVAILABLE = os.path.join(DEFAULT_IMGS_FOLDER, NOT_AVAILABLE)
|
||||
FONT_FILE = os.path.join(__BASE_FOLDER__, 'font.json')
|
||||
SETTINGS_FILE = os.path.join(__BASE_FOLDER__, "settings.json")
|
||||
|
||||
|
||||
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?"
|
||||
SCREEN_UPDATE_FAIL = "Unable to update the data"
|
||||
SCREEN_UPDATE_FAIL_MSG = "Downloaded data currupted or invalid"
|
||||
|
||||
|
||||
class ThemeConstants:
|
||||
"""Container class for all the theme-related constants."""
|
||||
|
||||
EXTENSION = ".qss"
|
||||
ICONS_FOLDER = "icons"
|
||||
DEFAULT = "dark"
|
||||
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)
|
||||
DEFAULT_THEME_PATH = os.path.join(FOLDER, DEFAULT)
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>400</width>
|
||||
<height>160</height>
|
||||
<height>185</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Download database</string>
|
||||
<string>Downloading</string>
|
||||
</property>
|
||||
<property name="windowIcon">
|
||||
<iconset resource="default_imgs.qrc">
|
||||
@@ -29,7 +29,7 @@
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Downloading database
|
||||
<string>Downloading updates
|
||||
Please wait...
|
||||
</string>
|
||||
</property>
|
||||
@@ -69,7 +69,7 @@ Please wait...
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QProgressBar" name="progressBar">
|
||||
<widget class="QProgressBar" name="_progress_bar">
|
||||
<property name="minimum">
|
||||
<number>0</number>
|
||||
</property>
|
||||
|
||||
@@ -2,7 +2,8 @@ from PyQt5 import uic
|
||||
from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
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
|
||||
|
||||
|
||||
@@ -12,10 +13,12 @@ Ui_Download_window, _ = uic.loadUiType(
|
||||
|
||||
|
||||
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()
|
||||
closed = pyqtSignal()
|
||||
_PROGRESS_CONEVERSION_FACTOR = 1024
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the window."""
|
||||
@@ -47,10 +50,22 @@ class DownloadWindow(QWidget, Ui_Download_window):
|
||||
self._download_thread.speed_progress.connect(self._display_speed)
|
||||
self.closed.connect(self._download_thread.set_exit)
|
||||
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."""
|
||||
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):
|
||||
"""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))
|
||||
elif progress == Constants.EXTRACTING_CODE:
|
||||
self.status_lbl.setText(Constants.EXTRACTING_MSG)
|
||||
if self._size > 0:
|
||||
self._progress_bar.setValue(progress * self._PROGRESS_CONEVERSION_FACTOR)
|
||||
|
||||
def show(self):
|
||||
"""Extends QWidget.show. Set downloaded MB and speed to zero."""
|
||||
|
||||
165
src/downloadtargetfactory.py
Normal file
165
src/downloadtargetfactory.py
Normal file
@@ -0,0 +1,165 @@
|
||||
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,
|
||||
SupportedOs.RASPBIAN: _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!")
|
||||
33
src/executable_utilities.py
Normal file
33
src/executable_utilities.py
Normal 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
847
src/filters.py
Normal 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()
|
||||
43
src/loggingconf.py
Normal file
43
src/loggingconf.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import logging
|
||||
import logging.config
|
||||
from constants import __BASE_FOLDER__
|
||||
import os.path
|
||||
|
||||
"""Import the module to initialize the logging configuration.
|
||||
|
||||
It is imported only for its side effects."""
|
||||
|
||||
|
||||
_LOGGING_CONFIG = {
|
||||
'version': 1,
|
||||
'formatters': {
|
||||
'general': {
|
||||
'format': '%(asctime)s::%(levelname)s::%(module)s::%(funcName)s::%(message)s',
|
||||
'datefmt': '%d/%m/%Y %I:%M:%S %p',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'level': 'INFO',
|
||||
'formatter': 'general',
|
||||
'class': 'logging.StreamHandler',
|
||||
'stream': 'ext://sys.stdout',
|
||||
},
|
||||
'file': {
|
||||
'class': 'logging.FileHandler',
|
||||
'level': 'ERROR',
|
||||
'filename': os.path.join(__BASE_FOLDER__, 'info.log'),
|
||||
'mode': 'w',
|
||||
'encoding': 'utf8',
|
||||
'formatter': 'general',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'level': 'DEBUG',
|
||||
'handlers': ['console', 'file'],
|
||||
},
|
||||
# Add loggers if required
|
||||
# 'loggers': {}
|
||||
}
|
||||
|
||||
logging.config.dictConfig(_LOGGING_CONFIG)
|
||||
38
src/os_utilities.py
Normal file
38
src/os_utilities.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import sys
|
||||
import platform
|
||||
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()
|
||||
IS_RASPBIAN = IS_LINUX and 'arm' in platform.machine().lower()
|
||||
|
||||
|
||||
def get_os():
|
||||
"""Get the name of the current running operating system."""
|
||||
if IS_WINDOWS:
|
||||
return SupportedOs.WINDOWS
|
||||
elif IS_LINUX:
|
||||
if IS_RASPBIAN:
|
||||
return SupportedOs.RASPBIAN
|
||||
return SupportedOs.LINUX
|
||||
elif IS_MAC:
|
||||
return SupportedOs.MAC
|
||||
else:
|
||||
return None
|
||||
43
src/settings.py
Normal file
43
src/settings.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import os.path
|
||||
from constants import Constants
|
||||
import json
|
||||
import logging
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Dynamically save and load the settings of the application."""
|
||||
|
||||
def __init__(self):
|
||||
self._dct = {}
|
||||
|
||||
def load(self):
|
||||
"""Load the setiings.json file."""
|
||||
if not os.path.exists(Constants.SETTINGS_FILE):
|
||||
return
|
||||
try:
|
||||
with open(Constants.SETTINGS_FILE, 'r') as settings_file:
|
||||
self._dct = json.load(settings_file)
|
||||
except FileNotFoundError:
|
||||
logging.info("No settings.json file")
|
||||
pass # Invalid file.
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Save the settings.json file.
|
||||
|
||||
Also update the current settings specified in kwargs.
|
||||
New settings can be dynamically added via this method."""
|
||||
for k, v in kwargs.items():
|
||||
self._dct[k] = v
|
||||
with open(Constants.SETTINGS_FILE, mode='w') as settings_file:
|
||||
json.dump(
|
||||
self._dct,
|
||||
settings_file,
|
||||
sort_keys=True,
|
||||
indent=4
|
||||
)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
"""Return the corresponding setting.
|
||||
|
||||
Return None if there is no such setting yet."""
|
||||
return self._dct.get(attr, None)
|
||||
298
src/spaceweathermanager.py
Normal file
298
src/spaceweathermanager.py
Normal file
@@ -0,0 +1,298 @@
|
||||
import logging
|
||||
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 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:
|
||||
try:
|
||||
xray_long = float(self._owner.space_weather_data.xray)
|
||||
|
||||
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("<A0.0")
|
||||
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 = float(self._owner.space_weather_data.prot_el)
|
||||
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 = int(self._owner.space_weather_data.ak_index[8][11].replace('.', ''))
|
||||
self._owner.k_index_lbl.setText(str(k_index))
|
||||
a_index = int(self._owner.space_weather_data.ak_index[7][7].replace('.', ''))
|
||||
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._owner.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 = int(self._owner.space_weather_data.geo_storm[6][index])
|
||||
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 = int(self._owner.space_weather_data.ak_index[7][2].replace('.', ''))
|
||||
self._owner.sfi_lbl.setText(f"{val}")
|
||||
val = int(
|
||||
[x[4] for x in self._owner.space_weather_data.sgas if "SSN" in x][0]
|
||||
)
|
||||
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()
|
||||
except Exception as e: # This is a mess, so log an error and give up
|
||||
logging.error(f"Forecast update failure: {e}")
|
||||
pop_up(
|
||||
self._owner,
|
||||
title=Messages.SCREEN_UPDATE_FAIL,
|
||||
text=Messages.SCREEN_UPDATE_FAIL_MSG
|
||||
).show()
|
||||
|
||||
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()
|
||||
@@ -260,8 +260,6 @@ QPushButton {
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
border: 2px dashed #4545e5;
|
||||
border-radius: 13px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
|
||||
@@ -113,58 +113,60 @@ QSpinBox::down-button:disabled {
|
||||
}
|
||||
|
||||
QPushButton{
|
||||
border-style: outset;
|
||||
/*border-style: outset;
|
||||
border-width: 2px;
|
||||
border-top-color: qlineargradient(spread:pad, x1:0.5, y1:0.6, x2:0.5, y2:0.4, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255));
|
||||
border-right-color: qlineargradient(spread:pad, x1:0.4, y1:0.5, x2:0.6, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255));
|
||||
border-left-color: qlineargradient(spread:pad, x1:0.6, y1:0.5, x2:0.4, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255));
|
||||
border-bottom-color: rgb(58, 58, 58);
|
||||
border-bottom-width: 1px;
|
||||
border-style: solid;
|
||||
border-style: solid;*/
|
||||
border: 0px;
|
||||
color: rgb(255, 255, 255);
|
||||
padding: 2px;
|
||||
background-color: qlineargradient(spread:pad, x1:0.5, y1:1, x2:0.5, y2:0, stop:0 rgba(77, 77, 77, 255), stop:1 rgba(97, 97, 97, 255));
|
||||
background-color: transparent;
|
||||
}
|
||||
QPushButton:hover{
|
||||
border-style: outset;
|
||||
/*border-style: outset;
|
||||
border-width: 2px;
|
||||
border-top-color: qlineargradient(spread:pad, x1:0.5, y1:0.6, x2:0.5, y2:0.4, stop:0 rgba(180, 180, 180, 255), stop:1 rgba(110, 110, 110, 255));
|
||||
border-right-color: qlineargradient(spread:pad, x1:0.4, y1:0.5, x2:0.6, y2:0.5, stop:0 rgba(180, 180, 180, 255), stop:1 rgba(110, 110, 110, 255));
|
||||
border-left-color: qlineargradient(spread:pad, x1:0.6, y1:0.5, x2:0.4, y2:0.5, stop:0 rgba(180, 180, 180, 255), stop:1 rgba(110, 110, 110, 255));
|
||||
border-bottom-color: rgb(115, 115, 115);
|
||||
border-bottom-width: 1px;
|
||||
border-style: solid;
|
||||
color: rgb(255, 255, 255);
|
||||
border-style: solid;*/
|
||||
border: 0px;
|
||||
color: #AFAFAF;
|
||||
padding: 2px;
|
||||
background-color: qlineargradient(spread:pad, x1:0.5, y1:1, x2:0.5, y2:0, stop:0 rgba(107, 107, 107, 255), stop:1 rgba(157, 157, 157, 255));
|
||||
}
|
||||
QPushButton:pressed{
|
||||
border-style: outset;
|
||||
/*border-style: outset;
|
||||
border-width: 2px;
|
||||
border-top-color: qlineargradient(spread:pad, x1:0.5, y1:0.6, x2:0.5, y2:0.4, stop:0 rgba(62, 62, 62, 255), stop:1 rgba(22, 22, 22, 255));
|
||||
border-right-color: qlineargradient(spread:pad, x1:0.4, y1:0.5, x2:0.6, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255));
|
||||
border-left-color: qlineargradient(spread:pad, x1:0.6, y1:0.5, x2:0.4, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255));
|
||||
border-bottom-color: rgb(58, 58, 58);
|
||||
border-bottom-width: 1px;
|
||||
border-style: solid;
|
||||
border-style: solid;*/
|
||||
border: 0px;
|
||||
color: rgb(255, 255, 255);
|
||||
padding: 2px;
|
||||
background-color: qlineargradient(spread:pad, x1:0.5, y1:1, x2:0.5, y2:0, stop:0 rgba(77, 77, 77, 255), stop:1 rgba(97, 97, 97, 255));
|
||||
}
|
||||
QPushButton:disabled{
|
||||
border-style: outset;
|
||||
/*border-style: outset;
|
||||
border-width: 2px;
|
||||
border-top-color: qlineargradient(spread:pad, x1:0.5, y1:0.6, x2:0.5, y2:0.4, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255));
|
||||
border-right-color: qlineargradient(spread:pad, x1:0.4, y1:0.5, x2:0.6, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255));
|
||||
border-left-color: qlineargradient(spread:pad, x1:0.6, y1:0.5, x2:0.4, y2:0.5, stop:0 rgba(115, 115, 115, 255), stop:1 rgba(62, 62, 62, 255));
|
||||
border-bottom-color: rgb(58, 58, 58);
|
||||
border-bottom-width: 1px;
|
||||
border-style: solid;
|
||||
border-style: solid;*/
|
||||
border: 0px;
|
||||
color: rgb(0, 0, 0);
|
||||
padding: 2px;
|
||||
background-color: qlineargradient(spread:pad, x1:0.5, y1:1, x2:0.5, y2:0, stop:0 rgba(57, 57, 57, 255), stop:1 rgba(77, 77, 77, 255));
|
||||
}
|
||||
QPushButton:checked {
|
||||
border: 0px;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
@@ -187,6 +189,10 @@ QRadioButton{
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
QRadioButton:hover{
|
||||
color: #AFAFAF;
|
||||
}
|
||||
|
||||
QComboBox {
|
||||
border: 0px solid transparent;
|
||||
border-radius: 2px;
|
||||
@@ -303,11 +309,11 @@ QTabWidget {
|
||||
background-color:rgb(247,246,246);
|
||||
}
|
||||
QTabWidget::pane {
|
||||
border-color: rgb(77,77,77);
|
||||
/* border-color: rgb(77,77,77); */
|
||||
background-color:rgb(101,101,101);
|
||||
border-style: solid;
|
||||
/* border-style: solid;
|
||||
border-width: 1px;
|
||||
border-radius: 6px;
|
||||
border-radius: 6px; */
|
||||
}
|
||||
QTabBar::tab {
|
||||
padding:2px;
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
from functools import partial
|
||||
from itertools import chain
|
||||
import os
|
||||
import re
|
||||
from PyQt5.QtWidgets import QAction, QActionGroup
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from constants import Constants
|
||||
from switchable_label import SwitchableLabelsIterable
|
||||
from constants import Constants, ThemeConstants
|
||||
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:
|
||||
"""Manage the theme's secondary colors.
|
||||
|
||||
@@ -46,7 +18,7 @@ class _ColorsHandler:
|
||||
|
||||
Can handle strings representing multiple colors."""
|
||||
|
||||
MAX_COLORS = 2
|
||||
_MAX_COLORS = 2
|
||||
|
||||
def __init__(self, line):
|
||||
"""Define the color from the string 'line'.
|
||||
@@ -76,7 +48,7 @@ class _ColorsHandler:
|
||||
return bool(re.match(pattern, col)) and len(col) == 7
|
||||
|
||||
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)
|
||||
else:
|
||||
return False
|
||||
@@ -120,59 +92,17 @@ class ThemeManager:
|
||||
self._theme_path = ""
|
||||
self._current_theme = ""
|
||||
|
||||
self._space_weather_labels = SwitchableLabelsIterable(
|
||||
*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(
|
||||
self._owner.spaceweather_screen.refreshable_labels.set(
|
||||
"switch_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
|
||||
)
|
||||
|
||||
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()
|
||||
def _apply(self, theme_path):
|
||||
def _apply(self, theme_path, save=True):
|
||||
"""Apply the selected theme.
|
||||
|
||||
Refresh all relevant widgets.
|
||||
@@ -180,17 +110,17 @@ class ThemeManager:
|
||||
self._theme_path = theme_path
|
||||
if os.path.exists(theme_path):
|
||||
if self._theme_path != self._current_theme:
|
||||
self._change()
|
||||
self._change(save)
|
||||
self._owner.display_specs(
|
||||
item=self._owner.signals_list.currentItem(),
|
||||
previous_item=None
|
||||
)
|
||||
self._refresh_range_labels()
|
||||
self._owner.audio_widget.refresh_btns_colors(
|
||||
self._owner.filters.refresh()
|
||||
self._owner.audio_widget.refresh(
|
||||
self._owner.active_color,
|
||||
self._owner.inactive_color
|
||||
)
|
||||
self._space_weather_labels.refresh()
|
||||
self._owner.spaceweather_screen.refreshable_labels.refresh()
|
||||
else:
|
||||
pop_up(self._owner, title=ThemeConstants.THEME_NOT_FOUND,
|
||||
text=ThemeConstants.MISSING_THEME).show()
|
||||
@@ -210,8 +140,12 @@ class ThemeManager:
|
||||
Connect all the actions to change the theme.
|
||||
Display a QMessageBox if the theme folder is not found."""
|
||||
themes = []
|
||||
ag = QActionGroup(self._owner, exclusive=True)
|
||||
if os.path.exists(ThemeConstants.FOLDER):
|
||||
ag = QActionGroup(self._owner)
|
||||
themes_menu = self._owner.settings_menu.addMenu("Themes")
|
||||
if not os.path.exists(ThemeConstants.FOLDER):
|
||||
pop_up(self._owner, title=ThemeConstants.THEME_FOLDER_NOT_FOUND,
|
||||
text=ThemeConstants.MISSING_THEME_FOLDER).show()
|
||||
return
|
||||
for theme_folder in sorted(os.listdir(ThemeConstants.FOLDER)):
|
||||
relative_folder = os.path.join(ThemeConstants.FOLDER, theme_folder)
|
||||
if os.path.isdir(os.path.abspath(relative_folder)):
|
||||
@@ -222,17 +156,15 @@ class ThemeManager:
|
||||
new_theme = ag.addAction(
|
||||
QAction(
|
||||
theme_name,
|
||||
self._owner, checkable=True
|
||||
self._owner,
|
||||
checkable=True
|
||||
)
|
||||
)
|
||||
self._owner.menu_themes.addAction(new_theme)
|
||||
themes_menu.addAction(new_theme)
|
||||
self._theme_names[theme_name.lstrip('&')] = new_theme
|
||||
new_theme.triggered.connect(partial(self._apply, theme_path))
|
||||
else:
|
||||
pop_up(self._owner, title=ThemeConstants.THEME_FOLDER_NOT_FOUND,
|
||||
text=ThemeConstants.MISSING_THEME_FOLDER).show()
|
||||
|
||||
def _change(self):
|
||||
def _change(self, save=True):
|
||||
"""Change the current theme.
|
||||
|
||||
Apply the stylesheet and set active and inactive colors.
|
||||
@@ -297,20 +229,20 @@ class ThemeManager:
|
||||
inactive_color_ok = True
|
||||
if color.quality == Constants.TEXT_COLOR:
|
||||
text_color_ok = True
|
||||
self._space_weather_labels.set(
|
||||
self._owner.spaceweather_screen.refreshable_labels.set(
|
||||
"text_color",
|
||||
color.color_str
|
||||
)
|
||||
for color in color_handler.double_color_list:
|
||||
if color.quality == Constants.LABEL_ON_COLOR:
|
||||
switch_on_color_ok = True
|
||||
self._space_weather_labels.set(
|
||||
self._owner.spaceweather_screen.refreshable_labels.set(
|
||||
"switch_on_colors",
|
||||
color.color_list
|
||||
)
|
||||
if color.quality == Constants.LABEL_OFF_COLOR:
|
||||
switch_off_color_ok = True
|
||||
self._space_weather_labels.set(
|
||||
self._owner.spaceweather_screen.refreshable_labels.set(
|
||||
"switch_off_colors",
|
||||
color.color_list
|
||||
)
|
||||
@@ -320,49 +252,48 @@ class ThemeManager:
|
||||
self._owner.inactive_color = ThemeConstants.DEFAULT_INACTIVE_COLOR
|
||||
|
||||
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",
|
||||
ThemeConstants.DEFAULT_ON_COLORS
|
||||
)
|
||||
self._space_weather_labels.set(
|
||||
self._owner.spaceweather_screen.refreshable_labels.set(
|
||||
"switch_off_colors",
|
||||
ThemeConstants.DEFAULT_OFF_COLORS
|
||||
)
|
||||
|
||||
if not text_color_ok:
|
||||
self._space_weather_labels.set(
|
||||
self._owner.spaceweather_screen.refreshable_labels.set(
|
||||
"text_color",
|
||||
ThemeConstants.DEFAULT_TEXT_COLOR
|
||||
)
|
||||
self._current_theme = self._theme_path
|
||||
if save:
|
||||
self._owner.settings.save(theme=os.path.basename(self._theme_path))
|
||||
|
||||
try:
|
||||
with open(ThemeConstants.CURRENT_THEME_FILE, "w") as current_theme:
|
||||
current_theme.write(self._theme_path)
|
||||
except Exception:
|
||||
pass
|
||||
def apply_default_theme(self):
|
||||
"""Apply the default theme if no theme is set or the theme name is invalid."""
|
||||
pretty_name = self._theme_names.get(self._pretty_name(ThemeConstants.DEFAULT), None)
|
||||
if pretty_name is None:
|
||||
pop_up(
|
||||
self._owner,
|
||||
title=ThemeConstants.THEME_NOT_FOUND,
|
||||
text=ThemeConstants.MISSING_THEME
|
||||
).show()
|
||||
else:
|
||||
pretty_name.setChecked(True)
|
||||
self._apply(ThemeConstants.DEFAULT_THEME_PATH)
|
||||
|
||||
def start(self):
|
||||
"""Start the theme manager."""
|
||||
self._detect_themes()
|
||||
if os.path.exists(ThemeConstants.CURRENT_THEME_FILE):
|
||||
with open(ThemeConstants.CURRENT_THEME_FILE, "r") as current_theme_path:
|
||||
theme_path = current_theme_path.read()
|
||||
if self._owner.settings.theme is not None:
|
||||
theme_path = os.path.join(ThemeConstants.FOLDER, self._owner.settings.theme)
|
||||
theme_name = self._pretty_name(os.path.basename(theme_path))
|
||||
try:
|
||||
self._theme_names[theme_name].setChecked(True)
|
||||
except Exception:
|
||||
pop_up(self._owner, title=ThemeConstants.THEME_NOT_FOUND,
|
||||
text=ThemeConstants.MISSING_THEME).show()
|
||||
theme = self._theme_names.get(theme_name, None)
|
||||
if theme is None:
|
||||
self.apply_default_theme()
|
||||
else:
|
||||
self._apply(theme_path)
|
||||
theme.setChecked(True)
|
||||
self._apply(theme_path, save=False)
|
||||
else:
|
||||
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)
|
||||
self.apply_default_theme()
|
||||
|
||||
177
src/threads.py
177
src/threads.py
@@ -2,18 +2,18 @@ import asyncio
|
||||
from enum import Enum, auto
|
||||
from io import BytesIO
|
||||
from math import ceil
|
||||
import os.path
|
||||
from shutil import rmtree
|
||||
import ssl
|
||||
from time import perf_counter
|
||||
from zipfile import ZipFile
|
||||
import aiohttp
|
||||
import urllib3
|
||||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
from constants import Constants, Database, ChecksumWhat
|
||||
from utilities import checksum_ok
|
||||
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, pyqtSlot
|
||||
from constants import Constants
|
||||
from utilities import checksum_ok, get_file_extension
|
||||
from web_utilities import (
|
||||
get_cacert_file,
|
||||
get_pool_manager,
|
||||
)
|
||||
# Needed for pyinstaller compilation.
|
||||
import encodings.idna
|
||||
import encodings.idna # noqa: 401
|
||||
|
||||
|
||||
class ThreadStatus(Enum):
|
||||
@@ -39,30 +39,31 @@ class BaseDownloadThread(QThread):
|
||||
super().__init__(parent)
|
||||
self.status = ThreadStatus.UNDEFINED
|
||||
|
||||
def __del__(self):
|
||||
"""Force the termination of the thread."""
|
||||
self.terminate()
|
||||
self.wait()
|
||||
# def __del__(self):
|
||||
# """Force the termination of the thread."""
|
||||
# self.terminate()
|
||||
# self.wait()
|
||||
|
||||
|
||||
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)
|
||||
speed_progress = pyqtSignal(float)
|
||||
_CHUNK = 128 * 1024
|
||||
_MEGA = 1024**2
|
||||
_DELTAT = 2
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, min_bytes=1024**2):
|
||||
"""Just call super().__init__."""
|
||||
self._db = None
|
||||
self._exit_call = False
|
||||
super().__init__()
|
||||
self._min_bytes = min_bytes
|
||||
self._data = None
|
||||
self._exit_call = False
|
||||
self._target = None
|
||||
|
||||
def _pretty_len(self, byte_obj):
|
||||
"""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():
|
||||
return int(mega)
|
||||
else:
|
||||
@@ -71,33 +72,30 @@ class DownloadThread(BaseDownloadThread):
|
||||
def _get_download_speed(self, data, delta):
|
||||
"""Return the download speed in MB/s."""
|
||||
return round(
|
||||
(len(data) / self._MEGA) / delta, 2
|
||||
(len(data) / self._min_bytes) / delta, 2
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def set_exit(self):
|
||||
"""Time to shutdown the thread.
|
||||
|
||||
Executed in the main thread."""
|
||||
self._exit_call = True
|
||||
|
||||
def run(self):
|
||||
"""Override QThread.run. Download the database, images and audio samples.
|
||||
def start(self, target):
|
||||
"""Start the thread. Set the correct download options first."""
|
||||
self._target = target
|
||||
super().start()
|
||||
|
||||
Handle all possible exceptions. Also extract the files
|
||||
in the local folder."""
|
||||
self.status = ThreadStatus.UNDEFINED
|
||||
self._db = None
|
||||
def _download_loop(self):
|
||||
"""Read a chunck of the downloaded data at every iteration."""
|
||||
raw_data = bytes(0)
|
||||
sub_data = bytes(0)
|
||||
try:
|
||||
self._db = urllib3.PoolManager().request(
|
||||
'GET',
|
||||
Database.LINK_LOC,
|
||||
preload_content=False,
|
||||
timeout=4.0
|
||||
)
|
||||
start = perf_counter()
|
||||
prev_downloaded = 0
|
||||
while True:
|
||||
try:
|
||||
data = self._db.read(self._CHUNK)
|
||||
data = self._data.read(self._CHUNK)
|
||||
except Exception:
|
||||
raise _SlowConnError
|
||||
else:
|
||||
@@ -106,8 +104,8 @@ class DownloadThread(BaseDownloadThread):
|
||||
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:
|
||||
# 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:
|
||||
@@ -116,52 +114,113 @@ class DownloadThread(BaseDownloadThread):
|
||||
)
|
||||
sub_data = bytes(0)
|
||||
start = perf_counter()
|
||||
if self._exit_call:
|
||||
self._data.release_conn()
|
||||
break
|
||||
return raw_data
|
||||
|
||||
def run(self):
|
||||
"""Override QThread.run. Download the database, images and audio samples.
|
||||
|
||||
Handle all possible exceptions. Also extract the files
|
||||
in the destination folder."""
|
||||
self.status = ThreadStatus.UNDEFINED
|
||||
self._data = None
|
||||
|
||||
try:
|
||||
self._data = get_pool_manager().request(
|
||||
'GET',
|
||||
self._target.url,
|
||||
preload_content=False,
|
||||
)
|
||||
raw_data = self._download_loop()
|
||||
if self._exit_call:
|
||||
self._exit_call = False
|
||||
self._db.release_conn()
|
||||
return
|
||||
except Exception as e: # No (or bad) internet connection.
|
||||
self._db.release_conn()
|
||||
self._data.release_conn()
|
||||
if isinstance(e, _SlowConnError):
|
||||
self.status = ThreadStatus.SLOW_CONN_ERR
|
||||
else:
|
||||
self.status = ThreadStatus.NO_CONNECTION_ERR
|
||||
return
|
||||
if self._db.status != 200:
|
||||
if self._data.status != 200:
|
||||
self.status = ThreadStatus.BAD_DOWNLOAD_ERR
|
||||
return
|
||||
try:
|
||||
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
|
||||
if self._wrong_checksum(raw_data):
|
||||
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 ValueError: # Invalid hash code.
|
||||
self.status = ThreadStatus.NO_CONNECTION_ERR
|
||||
return True
|
||||
else:
|
||||
if not is_checksum_ok:
|
||||
self.status = ThreadStatus.BAD_DOWNLOAD_ERR
|
||||
return
|
||||
if os.path.exists(Constants.DATA_FOLDER):
|
||||
rmtree(Constants.DATA_FOLDER)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract(self, raw_data):
|
||||
"""Unzip and save the downloaded data into the destination folder."""
|
||||
try:
|
||||
self.progress.emit(Constants.EXTRACTING_CODE)
|
||||
self.speed_progress.emit(Constants.ZERO_FINAL_SPEED)
|
||||
with ZipFile(BytesIO(raw_data)) as zipped:
|
||||
zipped.extractall()
|
||||
with self._target.Extractor.open(fileobj=BytesIO(raw_data)) as zipped:
|
||||
zipped.extractall(path=self._target.dest_path)
|
||||
except Exception:
|
||||
self.status = ThreadStatus.UNKNOWN_ERR
|
||||
else:
|
||||
self.status = ThreadStatus.OK
|
||||
|
||||
|
||||
class _AsyncDownloader:
|
||||
"""Mixin class for asynchronous threads."""
|
||||
class UpdatesControllerThread(BaseDownloadThread):
|
||||
|
||||
async def _download_resource(self, session, link):
|
||||
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
|
||||
|
||||
|
||||
async def _download_resource(session, link):
|
||||
"""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()
|
||||
|
||||
|
||||
class UpdateSpaceWeatherThread(BaseDownloadThread, _AsyncDownloader):
|
||||
class UpdateSpaceWeatherThread(BaseDownloadThread):
|
||||
"""Subclass BaseDownloadThread. Download the space weather data."""
|
||||
|
||||
_PROPERTIES = ("xray", "prot_el", "ak_index", "sgas", "geo_storm")
|
||||
@@ -174,14 +233,12 @@ class UpdateSpaceWeatherThread(BaseDownloadThread, _AsyncDownloader):
|
||||
async def _download_property(self, session, property_name):
|
||||
"""Download the data conteining the information of a specific property."""
|
||||
link = getattr(Constants, "SPACE_WEATHER_" + property_name.upper())
|
||||
data = await self._download_resource(session, link)
|
||||
setattr(self._space_weather_data, property_name, str(data, 'utf-8'))
|
||||
data = await _download_resource(session, link)
|
||||
self._space_weather_data.set_property(property_name, data, get_file_extension(link))
|
||||
|
||||
async def _download_image(self, session, n):
|
||||
"""Download the data corresponding the n-th image displayed in the screen."""
|
||||
im = await self._download_resource(
|
||||
session, Constants.SPACE_WEATHER_IMGS[n]
|
||||
)
|
||||
im = await _download_resource(session, Constants.SPACE_WEATHER_IMGS[n])
|
||||
self._space_weather_data.images[n].loadFromData(im)
|
||||
|
||||
async def _download_resources(self):
|
||||
@@ -216,7 +273,7 @@ class UpdateSpaceWeatherThread(BaseDownloadThread, _AsyncDownloader):
|
||||
asyncio.run(self._download_resources())
|
||||
|
||||
|
||||
class UpdateForecastThread(BaseDownloadThread, _AsyncDownloader):
|
||||
class UpdateForecastThread(BaseDownloadThread):
|
||||
"""Subclass BaseDownloadThread. Download the forecast data."""
|
||||
|
||||
class _PropertyName(Enum):
|
||||
@@ -231,7 +288,7 @@ class UpdateForecastThread(BaseDownloadThread, _AsyncDownloader):
|
||||
|
||||
async def _download_property(self, session, link, prop_name):
|
||||
"""Download the data from 'link' and set the corresponding property of the owner."""
|
||||
resp = await self._download_resource(session, link)
|
||||
resp = await _download_resource(session, link)
|
||||
resp = str(resp, 'utf-8')
|
||||
if prop_name is self._PropertyName.FORECAST:
|
||||
self.owner.forecast = resp
|
||||
|
||||
200
src/updater.py
Normal file
200
src/updater.py
Normal 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_())
|
||||
144
src/updatescontroller.py
Normal file
144
src/updatescontroller.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import logging
|
||||
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:
|
||||
logging.error("Unable to start updater")
|
||||
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 None
|
||||
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:
|
||||
logging.error("Unable to query the updater")
|
||||
updater_version = latest_updater_version
|
||||
if latest_updater_version is None:
|
||||
return None
|
||||
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
|
||||
)
|
||||
)
|
||||
80
src/urlbutton.py
Normal file
80
src/urlbutton.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from PyQt5.QtWidgets import QPushButton
|
||||
from collections import namedtuple
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class UrlButton(QPushButton):
|
||||
"""Define the behaviour of the wiki button."""
|
||||
|
||||
class State(Enum):
|
||||
"""Possible states of the button."""
|
||||
ACTIVE = auto()
|
||||
INACTIVE = auto()
|
||||
CLICKED = auto()
|
||||
|
||||
_UrlColors = namedtuple(
|
||||
"UrlColors",
|
||||
[
|
||||
"INACTIVE",
|
||||
"ACTIVE",
|
||||
"CLICKED",
|
||||
"ACTIVE_HOVER",
|
||||
"CLICKED_HOVER",
|
||||
]
|
||||
)
|
||||
_COLORS = _UrlColors(
|
||||
"#9f9f9f",
|
||||
"#4c75ff",
|
||||
"#942ccc",
|
||||
"#808FFF",
|
||||
"#DE78FF",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_enabled(self, state):
|
||||
"""Enable the button and set the stylesheet."""
|
||||
super().setEnabled(True)
|
||||
if state is self.State.ACTIVE:
|
||||
color = self._COLORS.ACTIVE
|
||||
else:
|
||||
color = self._COLORS.CLICKED
|
||||
self.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
border: 0px;
|
||||
background-color: transparent;
|
||||
color: {color};
|
||||
}}
|
||||
QPushButton::hover {{
|
||||
border: 0px;
|
||||
background-color: transparent;
|
||||
color: {self._COLORS.ACTIVE_HOVER};
|
||||
}}
|
||||
""")
|
||||
|
||||
def set_disabled(self):
|
||||
"""Disable the button and set the stylesheet."""
|
||||
super().setEnabled(False)
|
||||
self.setStyleSheet(f"""
|
||||
QPushButton:disabled {{
|
||||
border: 0px;
|
||||
background-color: transparent;
|
||||
color: {self._COLORS.INACTIVE};
|
||||
}}
|
||||
""")
|
||||
|
||||
def set_clicked(self):
|
||||
"""Apply the stylesheet for the clicked state."""
|
||||
self.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
border: 0px;
|
||||
background-color: transparent;
|
||||
color: {self._COLORS.CLICKED};
|
||||
}}
|
||||
QPushButton::hover {{
|
||||
border: 0px;
|
||||
background-color: transparent;
|
||||
color: {self._COLORS.CLICKED_HOVER};
|
||||
}}
|
||||
""")
|
||||
124
src/utilities.py
124
src/utilities.py
@@ -1,21 +1,50 @@
|
||||
import logging
|
||||
from functools import partial
|
||||
import hashlib
|
||||
import sys
|
||||
import os
|
||||
from pandas import read_csv
|
||||
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
from constants import Constants, Signal, Database, ChecksumWhat
|
||||
from constants import Constants, Signal
|
||||
|
||||
|
||||
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)
|
||||
class UniqueMessageBox(QMessageBox):
|
||||
"""Subclass of QMessageBox. Overrides only the exec method.
|
||||
|
||||
Only one instance of this class can execute super().exec() exec at a given time.
|
||||
If another instance is the the exec loop, calling exec simply return None."""
|
||||
|
||||
_open_message = False
|
||||
_font = None
|
||||
|
||||
@classmethod
|
||||
def set_font(cls, font):
|
||||
"""Store the font for all UniqueMessageBox(es)."""
|
||||
cls._font = font
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def setFont(self, font):
|
||||
"""Extends QMessageBox.setFont. Apply the font only if it is not None."""
|
||||
if font is not None:
|
||||
super().setFont(font)
|
||||
|
||||
def exec(self):
|
||||
"""Overrides QMessageBox.exec. Call the parent method if there are no
|
||||
other instances executing exec; also set the current font.
|
||||
Otherwise return None,"""
|
||||
if self.__class__._open_message:
|
||||
return None
|
||||
self.setFont(self._font)
|
||||
self.__class__._open_message = True
|
||||
answer = super().exec()
|
||||
self.__class__._open_message = False
|
||||
return answer
|
||||
|
||||
def show(self):
|
||||
"""Extends QMessageBox.show().
|
||||
|
||||
Set the font before showing the message."""
|
||||
self.setFont(self._font)
|
||||
super().show()
|
||||
|
||||
|
||||
def uncheck_and_emit(button):
|
||||
@@ -25,7 +54,30 @@ def uncheck_and_emit(button):
|
||||
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,
|
||||
connection=None,
|
||||
is_question=False,
|
||||
@@ -37,7 +89,7 @@ def pop_up(cls, title, text,
|
||||
connection -- a callable to connect the message when emitting the finished signal.
|
||||
is_question -- whether the message contains a question.
|
||||
default_btn -- the default button for the possible answer to the question."""
|
||||
msg = QMessageBox(cls)
|
||||
msg = UniqueMessageBox(instance)
|
||||
msg.setWindowTitle(title)
|
||||
msg.setText(text)
|
||||
if informative_text:
|
||||
@@ -51,24 +103,15 @@ def pop_up(cls, title, text,
|
||||
return msg
|
||||
|
||||
|
||||
def checksum_ok(data, what):
|
||||
"""Check whether the checksum of the 'data' argument is correct."""
|
||||
def checksum_ok(data, reference_hash_code):
|
||||
"""Check whether the checksum of the 'data' argument is correct.
|
||||
|
||||
Expects a sha256 code as argument."""
|
||||
if reference_hash_code is None:
|
||||
raise ValueError("ERROR: Invalid hash code.")
|
||||
code = hashlib.sha256()
|
||||
code.update(data)
|
||||
if what is ChecksumWhat.FOLDER:
|
||||
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
|
||||
return code.hexdigest() == reference_hash_code
|
||||
|
||||
|
||||
def connect_events_to_func(events_to_connect, fun_to_connect, fun_args):
|
||||
@@ -150,6 +193,25 @@ def safe_cast(value, cast_type, default=-1):
|
||||
try:
|
||||
r = cast_type(value)
|
||||
except Exception:
|
||||
logging.error("Cast type failure")
|
||||
r = default
|
||||
finally:
|
||||
return r
|
||||
|
||||
|
||||
def get_file_extension(file):
|
||||
"""Return the extension of a file. Return None if there is not such property."""
|
||||
components = file.split('.')
|
||||
if len(components) > 1:
|
||||
return components[-1]
|
||||
return None
|
||||
|
||||
|
||||
def get_value_from_list_of_dicts(iterable, callable_ok, key_value):
|
||||
"""Return a value from a dict inside a list of dicts.
|
||||
|
||||
The iterable is reversed first, then the value corresponding to the key key_value
|
||||
is returned from the first dict for which callable_ok(dict) returns True"""
|
||||
for d in reversed(iterable):
|
||||
if callable_ok(d):
|
||||
return d[key_value]
|
||||
|
||||
122
src/versioncontroller.py
Normal file
122
src/versioncontroller.py
Normal file
@@ -0,0 +1,122 @@
|
||||
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": ...
|
||||
}
|
||||
}
|
||||
"raspberry": {
|
||||
"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"""
|
||||
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
|
||||
dct_element = self._dct.get(attr, None)
|
||||
if dct_element is None:
|
||||
return None
|
||||
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
|
||||
@@ -1,13 +1,17 @@
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject
|
||||
from threads import (BaseDownloadThread,
|
||||
from threads import (
|
||||
BaseDownloadThread,
|
||||
UpdateSpaceWeatherThread,
|
||||
ThreadStatus,
|
||||
UpdateForecastThread)
|
||||
UpdateForecastThread
|
||||
)
|
||||
from constants import Constants
|
||||
from switchable_label import MultiColorSwitchableLabel
|
||||
from utilities import safe_cast
|
||||
from utilities import safe_cast, get_value_from_list_of_dicts
|
||||
|
||||
|
||||
class _BaseWeatherData(QObject):
|
||||
@@ -87,12 +91,35 @@ class SpaceWeatherData(_BaseWeatherData):
|
||||
"""Override _BaseWeatherData._parse_data.
|
||||
|
||||
Set all the data."""
|
||||
self.xray = self._double_split(self.xray)
|
||||
self.prot_el = self._double_split(self.prot_el)
|
||||
if self.xray is not None:
|
||||
self.xray = get_value_from_list_of_dicts(
|
||||
self.xray,
|
||||
lambda d: d["energy"] == "0.1-0.8nm",
|
||||
"flux"
|
||||
)
|
||||
if self.prot_el is not None:
|
||||
self.prot_el = get_value_from_list_of_dicts(
|
||||
self.prot_el,
|
||||
lambda d: d["energy"] == ">=10 MeV",
|
||||
"flux"
|
||||
)
|
||||
if self.ak_index is not None:
|
||||
self.ak_index = self._double_split(self.ak_index)
|
||||
if self.sgas is not None:
|
||||
self.sgas = self._double_split(self.sgas)
|
||||
if self.geo_storm is not None:
|
||||
self.geo_storm = self._double_split(self.geo_storm)
|
||||
|
||||
def set_property(self, property_name, data, extension):
|
||||
"""Set a property to the object. Format the data based on the extension."""
|
||||
if extension == 'txt':
|
||||
setattr(self, property_name, str(data, 'utf-8'))
|
||||
elif extension == 'json':
|
||||
setattr(self, property_name, json.loads(data))
|
||||
else:
|
||||
logging.error("Invalid file extension")
|
||||
setattr(self, property_name, None)
|
||||
|
||||
def remove_data(self):
|
||||
"""Remove the reference to all the data."""
|
||||
self.xray = ''
|
||||
@@ -345,6 +372,7 @@ class ForecastData(_BaseWeatherData):
|
||||
self._set_dates(forecast, rows["solar_row"])
|
||||
self._set_labels_values(labels_table)
|
||||
except Exception:
|
||||
logging.error("Update ForecastData failure")
|
||||
pass
|
||||
|
||||
def remove_data(self):
|
||||
|
||||
58
src/web_utilities.py
Normal file
58
src/web_utilities.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import logging
|
||||
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:
|
||||
return download_file(url, encoding="UTF-8").splitlines()[-1].split(Database.DELIMITER)
|
||||
except Exception:
|
||||
logging.error("Database metadata download failure")
|
||||
return None
|
||||
|
||||
|
||||
def get_folder_hash_code():
|
||||
f = _download_multiline_file_as_list()
|
||||
if f is not None:
|
||||
return f[0]
|
||||
return None
|
||||
|
||||
|
||||
def get_db_hash_code():
|
||||
f = _download_multiline_file_as_list()
|
||||
if f is not None:
|
||||
return f[1]
|
||||
return None
|
||||
@@ -60,19 +60,3 @@ This folder contains a third option to run Artemis 3 on your pc. The method of i
|
||||
> ### MacOS:
|
||||
>
|
||||
> 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*
|
||||
|
||||
Reference in New Issue
Block a user