diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..15ab875 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) 2022 Michael Clemens, DK1MI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e69de29..7e7483d 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,60 @@ +# ColorSpot + +This script is a command line DX cluster client. It adds the following benefits to the default telnet stream: + + * displays the DX station's country + * displays the DX station's continent + * displays if the DX station uses LotW + * downloads your LotW QSL file and marks all lines with countries that need to be confirmed + * displays lines in different colors depending on the continent or band (user configurable) + +# Screnshot + +![screenshot](/screenshot.png?raw=true "screenshot") + +# Installation + +ColorSpot needs Python 3 and the following libraries: + + * colored + * requests + * Telnet + * zipfile + +Furthermore, you need an account at LotW for some of teh features. + +Before installing ColorSpot, please make sure that pip, setuptools and wheel are installed and up-to-date: + +``` +# python3 -m pip install --upgrade pip setuptools wheel +``` + +Finally, install ColorSpot with pip: + +``` +# python3 -m pip install colorspot +``` + +# Updating + +To update colorspot, execute the following command: + +``` +# python3 -m pip install --upgrade colorspot +``` + +# Usage + + * execute the application with "colorspot" + * colorspot creates a default config file and states its location (e.g. _~/.colorspot.ini_) + * adapt _~/.colorspot.ini_ to your needs. Important setting are: + * cluster/host and cluster/port: Change this if you want to use another cluster server + * cluster/user: Enter here your call sign + * lotw/user: Enter here your lotw user name (your call sign) + * lotw/password: Enter here your lotw password + * lotw/mode: Enter here the mode you would like to filter the QSL download from LotW + * execute the application again with "colorspot" + +# License + +see ![LICENSE](LICENSE) diff --git a/colorspot.py b/colorspot.py index 158d86f..d2d0403 100755 --- a/colorspot.py +++ b/colorspot.py @@ -1,28 +1,49 @@ #!/usr/bin/env python3 +# pylint: disable=W1401,C0303 + + +""" ++---------------------------------------------------------------------+ +| | +| ___ _ ___ _ | +| / __|___| |___ _ _/ __|_ __ ___| |_ | +| | (__/ _ \ / _ \ '_\__ \ '_ \/ _ \ _| | +| \___\___/_\___/_| |___/ .__/\___/\__| | +| -= DK1MI =- |_| | +| | +| | +| A colorful cli DX cluster client with an LotW integration | +| | +| Author: Michael Clemens, DK1MI (colorspot@qrz.is) | +| | +| Documentation: Please see the README.md file | +| License: Please see the LICENSE file | +| Repository: https://git.qrz.is/clemens/colorspot | +| | ++---------------------------------------------------------------------+ +""" # Infos on colors: https://pypi.org/project/colored/ -# Country list extracted from http://www.arrl.org/files/file/dxcclist.txt - import sys import csv import re -import json -import os -import time as bla -from telnetlib import Telnet -from colored import fg, bg, attr -import configparser -from collections import defaultdict import random -import requests +import os from os.path import exists +import configparser import zipfile +from telnetlib import Telnet +import requests +from colored import fg, bg, attr class ColorSpot(): + """ColorSpot class""" def __init__(self): + """initialize things""" + self.version = "0.1.0" self.print_banner() @@ -37,76 +58,14 @@ class ColorSpot(): self.confirmed_entities = self.get_confirmed_entities() if self.check_cty: - self.cty = list(csv.reader(open(self.config['files']['cty'], "r"), delimiter=",")) + #self.cty = list(csv.reader(open(self.config['files']['cty'], "r"), delimiter=",")) + with open(self.config['files']['cty'], encoding='us-ascii') as csvfile: + self.cty = list(csv.reader(csvfile, delimiter=',')) - @staticmethod - def rnd_col(): - r = lambda: random.randint(0,255) - return'#%02X%02X%02X' % (r(),r(),r()) - - @staticmethod - def download_file(url, local_filename): - with requests.get(url, stream=True) as r: - r.raise_for_status() - with open(local_filename, 'wb') as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) - return local_filename - - def check_files(self): - # check for lotw qsl information file - self.check_lotw_confirmed = exists(self.config['files']['lotw_confirmed']) - if not self.check_lotw_confirmed: - print("The file " + self.config['files']['lotw_confirmed'] + " is missing.") - user = self.config['lotw']['user'] - password = self.config['lotw']['password'] - mode = self.config['lotw']['mode'] - url = "https://lotw.arrl.org/lotwuser/lotwreport.adi?login={}&password={}&qso_query=1&qso_qsl=yes&qso_mode={}&qso_qsldetail=yes&qso_qslsince=1970-01-01".format(user, password, mode) - print("Trying to download " + url) - file_name = self.download_file(url, self.config['files']['lotw_confirmed']) - self.check_lotw_confirmed = exists(self.config['files']['lotw_confirmed']) - if self.check_lotw_confirmed: - print("File successfully downloaded") - else: - print("something went wrong while downloading " + url) - - - - # check for cty.csv file - self.check_cty = exists(self.config['files']['cty']) - if not self.check_cty: - url = self.config['files']['cty_url'] - print("The file " + self.config['files']['cty'] + " is missing.") - print("Trying to download " + url) - zip_name = self.download_file(url, "bigcty.zip" ) - with zipfile.ZipFile(zip_name, 'r') as zip_ref: - zip_ref.extract("cty.csv") - os.remove(zip_name) - self.check_cty = exists(self.config['files']['cty']) - if self.check_cty: - print("File successfully downloaded and extracted.") - else: - print("something went wrong while downloading " + url) - - # check for lotw user activity file - self.check_lotw_activity = exists(self.config['files']['lotw_activity']) - if not self.check_lotw_activity: - url = self.config['files']['lotw_activity_url'] - print("The file " + self.config['files']['lotw_activity'] + " is missing.") - print("Trying to download " + url) - file_name = self.download_file(url, self.config['files']['lotw_activity']) - self.check_lotw_activity = exists(self.config['files']['lotw_activity']) - if self.check_lotw_activity: - print("File successfully downloaded") - else: - print("something went wrong while downloading " + url) - def print_banner(self): """print an awesome banner""" - ver = self.version - # print the banner print(fg(self.rnd_col())+" ___ _ ___ _ ") print(fg(self.rnd_col())+" / __|___| |___ _ _/ __|_ __ ___| |_ ") print(fg(self.rnd_col())+" | (__/ _ \ / _ \ '_\__ \ '_ \/ _ \ _|") @@ -115,6 +74,7 @@ class ColorSpot(): print("") print(attr('reset')) + @staticmethod def read_config(config, file_name): """reads the configuration from the config file or @@ -136,7 +96,7 @@ class ColorSpot(): 'lotw_activity_url': 'https://lotw.arrl.org/lotw-user-activity.csv'} config['lotw'] = { 'user': 'N0CALL', - 'password': 'XXXXXXXXX', + 'password': 'CHANGEME', 'mode': 'ssb'} config['band_colors'] = { "145": "white", @@ -171,41 +131,119 @@ class ColorSpot(): 'alert_fg': 'white', 'default_bg': 'black'} - with open(file_name, 'w') as configfile: + with open(file_name, 'w', encoding='us-ascii') as configfile: config.write(configfile) print("\nNo configuration file found. A new configuration file has been created.") print("\nPlease edit the file " + file_name + " and restart the application.\n" ) sys.exit() return config + + @staticmethod + def rnd_col(): + """generates a random color cod ein hex""" + rand = lambda: random.randint(0,255) + return'#%02X%02X%02X' % (rand(),rand(),rand()) + + + @staticmethod + def download_file(url, local_filename): + """downloads a file via HTTP and saves it to a defined file""" + with requests.get(url, stream=True) as request: + request.raise_for_status() + with open(local_filename, 'wb') as file: + for chunk in request.iter_content(chunk_size=8192): + file.write(chunk) + return local_filename + + + def check_files(self): + """Checks if all necessary files are in the file system. + Downloads all files and unzips them (if necessary)""" + # check for lotw qsl information file + self.check_lotw_confirmed = exists(self.config['files']['lotw_confirmed']) + if not self.check_lotw_confirmed: + print("The file " + self.config['files']['lotw_confirmed'] + " is missing.") + user = self.config['lotw']['user'] + password = self.config['lotw']['password'] + mode = self.config['lotw']['mode'] + url = "https://lotw.arrl.org/lotwuser/lotwreport.adi?login={}&password={}"\ + "&qso_query=1&qso_qsl=yes&qso_mode={}&qso_qsldetail=yes&"\ + "qso_qslsince=1970-01-01".format(user, password, mode) + print("Trying to download " + url) + self.download_file(url, self.config['files']['lotw_confirmed']) + self.check_lotw_confirmed = exists(self.config['files']['lotw_confirmed']) + if self.check_lotw_confirmed: + print("File successfully downloaded") + else: + print("something went wrong while downloading " + url) + + # check for cty.csv file + self.check_cty = exists(self.config['files']['cty']) + if not self.check_cty: + url = self.config['files']['cty_url'] + print("The file " + self.config['files']['cty'] + " is missing.") + print("Trying to download " + url) + zip_name = self.download_file(url, "bigcty.zip" ) + with zipfile.ZipFile(zip_name, 'r') as zip_ref: + zip_ref.extract("cty.csv") + os.remove(zip_name) + self.check_cty = exists(self.config['files']['cty']) + if self.check_cty: + print("File successfully downloaded and extracted.") + else: + print("something went wrong while downloading " + url) + + # check for lotw user activity file + self.check_lotw_activity = exists(self.config['files']['lotw_activity']) + if not self.check_lotw_activity: + url = self.config['files']['lotw_activity_url'] + print("The file " + self.config['files']['lotw_activity'] + " is missing.") + print("Trying to download " + url) + self.download_file(url, self.config['files']['lotw_activity']) + self.check_lotw_activity = exists(self.config['files']['lotw_activity']) + if self.check_lotw_activity: + print("File successfully downloaded") + else: + print("something went wrong while downloading " + url) + + def get_confirmed_entities(self): + """Reads the file downlaoded from LotW with all confirmed QSOs, + extracts all confirmed DXCCs and puts them into a list""" ret = [] - #TODO: download file and/or tell user what to do - file = open(self.config['files']['lotw_confirmed'], "r") - for row in file: - if re.search("")[2].lower().rstrip() - if dxcc not in ret: - ret.append(dxcc) + with open(self.config['files']['lotw_confirmed'], encoding='us-ascii') as file: + for row in file: + if re.search("")[2].lower().rstrip() + if dxcc not in ret: + ret.append(dxcc) return ret + def check_lotw(self, call): + """Reads the LotW user activity file and returns the date + of the last upload date if a specific call sign""" ret = "" - #TODO: download file and/or tell user what to do - csv_file = csv.reader(open(self.config['files']['lotw_activity'], "r"), delimiter=",") - #loop through the csv file - for row in csv_file: - if call == row[0]: - ret = row[1] - return ret + #csv_file = csv.reader(open(self.config['files']['lotw_activity'], "r"), delimiter=",") + with open(self.config['files']['lotw_activity'], encoding='us-ascii') as csvfile: + csv_file = csv.reader(csvfile, delimiter=',') + #loop through the csv file + for row in csv_file: + if call == row[0]: + ret = row[1] + return ret return ret + def get_cty_row(self, call): + """Parses all CTY records, tries to find the DXCC entity of a + specific call sign and returns the line as a list of strings""" done = False while not done: for row in self.cty: entities = row[9].replace(";", "").replace("=", "").split(" ") - # TODO: schauen ob = davor und match -> als special call anzeigen + # TODO: Check if it is a speciall call (=) and mark it in the list for prefix in entities: if call == prefix: return row @@ -216,14 +254,19 @@ class ColorSpot(): def get_spots(self): + """Connects to the specified telnet dx cluster, performs a login, grabs the + output row by row, enriches it with data and colorizes it depending on certain + paramaeters, e.g. by band or continent.""" with Telnet(self.config['cluster']['host'], int(self.config['cluster']['port']), \ int(self.config['cluster']['timeout'])) as telnet: while True: line_enc = telnet.read_until(b"\n") # Read one line line = line_enc.decode('ascii') + # Enters the call sign if requested if "enter your call" in line: b_user = str.encode(self.config['cluster']['user']+"\n") telnet.write(b_user) + # Detects the beginning of the stream and generates a header line elif " Hello " in line: print(fg("grey_27") + line + attr("reset")) foreground = "white" @@ -231,20 +274,25 @@ class ColorSpot(): sep = fg("grey_27")+'|'+fg(foreground) row = ["DE", sep, "Freq", sep, "DX", \ sep, "Country", sep, "C", sep, "L", sep, "Comment", sep, "Time"] - print(bg(background) + fg(foreground) + \ - '{:9.9} {:<1} {:>7.7} {:<1} {:<10.10} {:<1} {:<16.16} {:<1} {:<2.2} {:<1} {:<1.1} {:<1} {:<30.30} {:<1} {:<4.4}'.format(*row) + attr("reset")) + '{:9.9} {:<1} {:>7.7} {:<1} {:<10.10} {:<1} '\ + '{:<16.16} {:<1} {:<2.2} {:<1} {:<1.1} {:<1} {:<30.30} '\ + '{:<1} {:<4.4}'.format(*row) + attr("reset")) b_cmd = str.encode("sh/dx/50 @\n") telnet.write(b_cmd) + # This is true for every line representing a spot elif "DX de" in line or "Dx de" in line: try: - band_col = "" + # Extract all necessary fields from the line and store them + # into different variables. call_de = re.search('D(X|x) de (.+?): ', line).group(2) freq = re.search(': +(.+?) ', line).group(1) call_dx = re.search(freq + ' +(.+?) ', line).group(1) time = re.search('[^ ]*$', line).group(0)[0:4] comment = re.search(call_dx + ' +(.+?) +' + time, line).group(1) + # If the CTY file is available, further information will be + # gathered from it, e.g. continent, country, dxcc ID if self.check_cty: cty_details = self.get_cty_row(call_dx) else: @@ -253,11 +301,16 @@ class ColorSpot(): areaname = cty_details[1] continent = cty_details[3] + # If the LotW user activity file is available and the call + # sign in question is actually a LotW user, a checkmark is + # displayed in a dedicated column of the output if self.check_lotw_activity and self.check_lotw(call_dx): lotw = "✓" else: lotw = "" - + + # Depending on the user's choice, the row will be color coded + # depending on the band or the DX station's continent try: if self.config['colors']['color_by'] == "band": foreground = self.config['band_colors'][freq[:-5]] @@ -268,29 +321,34 @@ class ColorSpot(): except Exception: foreground = "white" + # Removes the trailing .0 from a frequency for better readability freq = freq.replace('.0', '') - if self.check_lotw_confirmed and cty_details[2] not in self.confirmed_entities: + # If the DX station's entity hasn't been worked/confirmed via + # LotW yet, the row's background will be color coded. + if self.check_lotw_confirmed and \ + cty_details[2] not in self.confirmed_entities: background = self.config['colors']['alert_bg'] foreground = self.config['colors']['alert_fg'] else: background = self.config['colors']['default_bg'] + # color of the table separator sep = fg("grey_27")+'|'+fg(foreground) + # Contructs the row that will be printed row = [call_de, sep, freq, sep, call_dx, \ sep, areaname, sep, continent, sep, lotw, sep, comment, sep, time] - print(bg(background) + fg(foreground) + \ - '{:9.9} {:<1} {:>7.7} {:<1} {:<10.10} {:<1} {:<16.16} {:<1} {:<2.2} {:<1} {:<1.1} {:<1} {:<30.30} {:<1} {:<4.4}'.format(*row) + attr("reset")) + '{:9.9} {:<1} {:>7.7} {:<1} {:<10.10} {:<1} '\ + '{:<16.16} {:<1} {:<2.2} {:<1} {:<1.1} {:<1} {:<30.30} '\ + '{:<1} {:<4.4}'.format(*row) + attr("reset")) except AttributeError: print(line) -##################################################### -# Main Routine # -##################################################### def main(): + """main routine""" try: color_spot = ColorSpot() color_spot.get_spots() @@ -302,6 +360,3 @@ if __name__ == "__main__": sys.exit(main()) except EOFError: pass - - - diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..4376722 Binary files /dev/null and b/screenshot.png differ