#!/usr/bin/env python3 # Copyright (C) 2013 Christian T. Jacobs. # This file is part of PyQSO. # PyQSO 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. # # PyQSO 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 PyQSO. If not, see . from gi.repository import Gtk, GObject import logging import telnetlib import unittest import unittest.mock import configparser import os.path from pyqso.telnet_connection_dialog import * BOOKMARKS_FILE = os.path.expanduser('~/.config/pyqso/bookmarks.ini') class DXCluster(Gtk.VBox): """ A tool for connecting to a DX cluster (specifically Telnet-based DX clusters). """ def __init__(self, parent): """ Set up the DX cluster's Gtk.VBox, and set up a timer so that PyQSO can retrieve new data from the Telnet server every few seconds. :arg parent: The parent Gtk window. """ logging.debug("Setting up the DX cluster...") Gtk.VBox.__init__(self, spacing=2) self.connection = None self.parent = parent # Set up the menubar self.menubar = Gtk.MenuBar() self.items = {} # CONNECTION ###### mitem_connection = Gtk.MenuItem(label="Connection") self.menubar.append(mitem_connection) subm_connection = Gtk.Menu() mitem_connection.set_submenu(subm_connection) # Connect mitem_connect = Gtk.ImageMenuItem(label="Connect to Telnet Server") icon = Gtk.Image() icon.set_from_stock(Gtk.STOCK_CONNECT, Gtk.IconSize.MENU) mitem_connect.set_image(icon) subm_connection.append(mitem_connect) self.items["CONNECT"] = mitem_connect subm_connect = Gtk.Menu() # New mitem_new = Gtk.MenuItem(label="New...") mitem_new.connect("activate", self.new_server) subm_connect.append(mitem_new) # From Bookmark mitem_bookmark = Gtk.MenuItem(label="From Bookmark") self.subm_bookmarks = Gtk.Menu() mitem_bookmark.set_submenu(self.subm_bookmarks) self._populate_bookmarks() subm_connect.append(mitem_bookmark) mitem_connect.set_submenu(subm_connect) # Disconnect mitem_disconnect = Gtk.ImageMenuItem(label="Disconnect from Telnet Server") icon = Gtk.Image() icon.set_from_stock(Gtk.STOCK_DISCONNECT, Gtk.IconSize.MENU) mitem_disconnect.set_image(icon) mitem_disconnect.connect("activate", self.telnet_disconnect) subm_connection.append(mitem_disconnect) self.items["DISCONNECT"] = mitem_disconnect self.pack_start(self.menubar, False, False, 0) # A TextView object to display the output from the Telnet server. self.renderer = Gtk.TextView() self.renderer.set_editable(False) self.renderer.set_cursor_visible(False) sw = Gtk.ScrolledWindow() sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) sw.add(self.renderer) self.buffer = self.renderer.get_buffer() self.pack_start(sw, True, True, 0) # Set up the command box. self.commandbox = Gtk.HBox(spacing=2) self.command = Gtk.Entry() self.commandbox.pack_start(self.command, True, True, 0) self.send = Gtk.Button(label="Send Command") self.send.connect("clicked", self.telnet_send_command) self.commandbox.pack_start(self.send, False, False, 0) self.pack_start(self.commandbox, False, False, 0) self.set_items_sensitive(True) self.show_all() logging.debug("DX cluster ready!") return def new_server(self, widget=None): """ Get Telnet server host and login details specified in the Gtk.Entry boxes in the TelnetConnectionDialog and attempt a connection. """ dialog = TelnetConnectionDialog(self.parent) response = dialog.run() if(response == Gtk.ResponseType.OK): connection_info = dialog.get_connection_info() host = connection_info["HOST"].get_text() port = connection_info["PORT"].get_text() username = connection_info["USERNAME"].get_text() password = connection_info["PASSWORD"].get_text() # Save the server details in a new bookmark, if desired. if(connection_info["BOOKMARK"].get_active()): try: config = configparser.ConfigParser() config.read(BOOKMARKS_FILE) # Use the host name as the bookmark's identifier. try: config.add_section(host) except configparser.DuplicateSectionError: # If the hostname already exists, assume the user wants to update the port number, username and/or password. logging.warning("A server with hostname '%s' already exists. Over-writing existing details..." % (host)) config.set(host, "host", host) config.set(host, "port", port) config.set(host, "username", username) config.set(host, "password", password) # Write the bookmarks to file. if not os.path.exists(os.path.expanduser('~/.config/pyqso')): os.makedirs(os.path.expanduser('~/.config/pyqso')) with open(BOOKMARKS_FILE, 'w') as f: config.write(f) self._populate_bookmarks() except IOError: # Maybe the bookmarks file could not be written to? logging.error("Bookmark could not be saved. Check bookmarks file permissions? Going ahead with the server connection anyway...") dialog.destroy() try: # Convert port (currently of type str) into an int. port = int(port) # Attempt a connection with the server. self.telnet_connect(host, port, username, password) except ValueError as e: logging.error("Could not convert the server's port information to an integer.") logging.exception(e) else: dialog.destroy() return def _populate_bookmarks(self): """ Populate the list of bookmarked Telnet servers in the menu. """ config = configparser.ConfigParser() have_config = (config.read(BOOKMARKS_FILE) != []) if(have_config): try: # Clear the menu of all current bookmarks. for i in self.subm_bookmarks.get_children(): self.subm_bookmarks.remove(i) # Add all bookmarks in the config file. for bookmark in config.sections(): mitem = Gtk.MenuItem(label=bookmark) mitem.connect("activate", self.bookmarked_server, bookmark) self.subm_bookmarks.append(mitem) except Exception as e: logging.error("An error occurred whilst populating the DX cluster bookmarks menu.") logging.exception(e) self.show_all() # Need to do this to update the bookmarks list in the menu. return def bookmarked_server(self, widget, name): """ Get Telnet server host and login details from an existing bookmark and attempt a connection. :arg str name: The name of the bookmark. This is the same as the server's hostname. """ config = configparser.ConfigParser() have_config = (config.read(BOOKMARKS_FILE) != []) try: if(not have_config): raise IOError("The bookmark's details could not be loaded.") host = config.get(name, "host") port = int(config.get(name, "port")) username = config.get(name, "username") password = config.get(name, "password") self.telnet_connect(host, port, username, password) except ValueError as e: # This exception may occur when casting the port (which is a str) to an int. logging.exception(e) except IOError as e: logging.exception(e) except Exception as e: logging.error("Could not connect to Telnet server '%s'" % name) logging.exception(e) return def telnet_connect(self, host, port=23, username=None, password=None): """ Connect to a user-specified Telnet server. :arg str host: The Telnet server's hostname. :arg int port: The Telnet server's port number. If no port is specified, the default Telnet server port of 23 will be used. :arg str username: The user's username. This is an optional argument. :arg str password: The user's password. This is an optional argument. """ if(host == "" or host is None): logging.error("No Telnet server specified.") return if(port == "" or port is None): port = 23 # Use the default Telnet port try: self.connection = telnetlib.Telnet(host, port) if(username): self.connection.read_until("login: ".encode()) self.connection.write((username + "\n").encode()) if(password): self.connection.read_until("password: ".encode()) self.connection.write((password + "\n").encode()) except Exception as e: logging.error("Could not create a connection to the Telnet server") logging.exception(e) self.connection = None return self.set_items_sensitive(False) self.check_io_event = GObject.timeout_add(1000, self._on_telnet_io) return def telnet_disconnect(self, widget=None): """ Disconnect from a Telnet server and remove the I/O timer. """ if(self.connection): self.connection.close() self.buffer.set_text("") self.connection = None self.set_items_sensitive(True) # Stop checking for server output once disconnected. try: GObject.source_remove(self.check_io_event) except AttributeError: # This may happen if a connection hasn't yet been established. pass return def telnet_send_command(self, widget=None): """ Send the user-specified command in the Gtk.Entry box to the Telnet server (if PyQSO is connected to one). """ if(self.connection): self.connection.write((self.command.get_text() + "\n").encode()) self.command.set_text("") return def _on_telnet_io(self): """ Retrieve any new data from the Telnet server and print it out in the Gtk.TextView widget. :returns: Always returns True to satisfy the GObject timer. :rtype: bool """ if(self.connection): text = self.connection.read_very_eager().decode() try: text = text.replace("\u0007", "") # Remove the BEL Unicode character from the end of the line except UnicodeDecodeError: pass # Allow auto-scrolling to the new text entry if the focus is already at # the very end of the Gtk.TextView. Otherwise, don't auto-scroll # in case the user is reading something further up. # Note: This is based on the code from http://forums.gentoo.org/viewtopic-t-445598-view-next.html end_iter = self.buffer.get_end_iter() end_mark = self.buffer.create_mark(None, end_iter) self.renderer.move_mark_onscreen(end_mark) at_end = self.buffer.get_iter_at_mark(end_mark).equal(end_iter) self.buffer.insert(end_iter, text) if(at_end): end_mark = self.buffer.create_mark(None, end_iter) self.renderer.scroll_mark_onscreen(end_mark) return True def set_items_sensitive(self, sensitive): """ Enable/disable the relevant buttons for connecting/disconnecting from a DX cluster, so that users cannot click the connect button if PyQSO is already connected. :arg bool sensitive: If True, enable the Connect button and disable the Disconnect button. If False, vice versa. """ self.items["CONNECT"].set_sensitive(sensitive) self.items["DISCONNECT"].set_sensitive(not sensitive) self.send.set_sensitive(not sensitive) return class TestDXCluster(unittest.TestCase): """ The unit tests for the DXCluster class. """ def setUp(self): """ Set up the objects needed for the unit tests. """ self.dxcluster = DXCluster(parent=None) def tearDown(self): """ Destroy any unit test resources. """ pass def test_on_telnet_io(self): """ Check that the response from the Telnet server can be correctly decoded. """ telnetlib.Telnet = unittest.mock.Mock(spec=telnetlib.Telnet) connection = telnetlib.Telnet("hello", "world") connection.read_very_eager.return_value = b"Test message from the Telnet server." self.dxcluster.connection = connection result = self.dxcluster._on_telnet_io() assert(result) if(__name__ == '__main__'): unittest.main()