# THIS IS THE ENTIRE "RADIO" FOR QUISK RUNNING AS A CONTROL HEAD # No real radio hardware is attached to the control_head computer. # # This software is Copyright (C) 2021-2022 by Ben Cahill and 2006-2022 by James C. Ahlstrom., # and is licensed for use under the GNU General Public License (GPL). # See http://www.opensource.org. # Note that there is NO WARRANTY AT ALL. USE AT YOUR OWN RISK!! # # This file, control_common.py, along with a radio-specific file, e.g. # control_softrock.py, control_hermes.py, or similar, allows a radio-less (control_head) Quisk, # running on this computer, to connect to a remote (remote_radio) instance of Quisk # that runs on a separate computer. # # The remote_radio Quisk controls an attached real radio, and uses the hardware file # remote_common.py, along with a radio-specific file, e.g. remote_softrock.py, # remote_hermes.py, or similar, to communicate with this control_head Quisk # computer via a network connection. # # The remote_radio computer should be set up with a static IP address, so that you know # where to point the control_head. The control head computer may, however, use dynamic # addressing; the remote radio computer will read the control head address when the # remote control connection is made. # # The main control interface between control_head and remote_radio is via a TCP port; # this uses very low bandwidth. All functional control, including CW keying, is done via this port. # # There are 2 additional ports, both UDP, using low to moderate bandwidth: # -- Receive graph/waterfall data from the remote_radio # -- Receive radio sound from the remote_radio and send mic samples # These use sequential port numbers based on the TCP port number self.remote_ctl_base_port. # If you need to change the default base port number, you can edit the line in this file # that looks like (without the #): # # self.remote_ctl_base_port = 4585 # Remote Control base TCP port # self.graph_data_port = self.remote_ctl_base_port + 1 # UDP port for graph data # self.remote_radio_sound_port = self.remote_ctl_base_port + 2 # UDP port for radio sound and mic # # Make sure to edit the corresponding line in remote_common.py to match ports!! # # You should be able to use the Quisk control_head along with any/all means of control that # you normally use to control Quisk, including serial ports, MIDI, and hamlib/rigctl interfaces. # # The remote_radio Quisk/computer is assumed to track the local control_head Quisk/computer; # no attempt is made to verify the remote_radio Quisk's tuning frequency, mode, etc. # Snap-to Rx tuning for CW works on the control_head Quisk by virtue of graph/waterfall data # received from the remote_radio Quisk. # # To test CW key timing, set DEBUG_CW_SEND_DITS = 1. This issues "perfect" bursts of dits, # configurable in terms of dit length, dit space, number of dits per burst, and pause between # bursts (phrases). Search in this file for "DEBUG_CW_SEND_DITS" to find configurable variables. # To get log output from remote_radio end, in quisk_hardware_remote_radio.py, set DEBUG_CW_JITTER = 1. from __future__ import print_function from __future__ import absolute_import DEBUG_CW_JITTER = 0 DEBUG_CW_SEND_DITS = 0 DEBUG = 0 import socket, time, traceback, hmac, threading, select import _quisk as QS # Access Quisk C functions via PyMethodDef QuiskMethods[] in quisk.c import wx from quisk_hardware_model import Hardware as BaseHardware class ControlCommon(BaseHardware): # This is the Hardware class for the control head def __init__(self, app, conf): BaseHardware.__init__(self, app, conf) self.app = app # Access Quisk class App (Python) functions app.remote_control_head = True self.remote_ctl_base_port = 4585 # Base of ports for remote connection (maybe edit this) self.remote_ctl_socket = None self.remote_ctl_connected = False self.remote_ctl_timestamp = None self.graph_data_port = self.remote_ctl_base_port + 1 self.remote_radio_sound_port = self.remote_ctl_base_port + 2 self.thread_lock = threading.Lock() self.remote_radio_ip = socket.gethostbyname(self.conf.remote_radio_ip) # Allow either host name or IP address self.first_heartbeat = True self.cw_keydown = 0 self.cw_phrase_begin_ts = None # timestamp of beginning of cw phrase self.cw_phrase_end_ts = None self.cw_phrase_break_duration_secs = 1.0 # cw timestamps will reset to 0 self.cw_poll_started_ts = None self.cw_poll_started = False if DEBUG_CW_SEND_DITS: self.dit_width = 100 # msec (configurable) self.space_width = 100 # msec (configurable) self.phrase_gap = 1000 # msec (configurable) self.num_dits_in_phrase = 5 # number (configurable) self.num_dits_cur_count = 0 self.key_was_down = False self.send_cw_dits = False self.cw_test_next_ts = None self.cw_test_next_msec = None self.cw_phrase_start_ts = None self.smeter_text = '' self.received = '' self.closing = False QS.set_sparams(remote_control_head=1, remote_control_slave=0) def open(self): ret = BaseHardware.open(self) self.remote_ctl_timestamp = time.time() passw = self.app.local_conf.globals.get("remote_radio_password", "") passw = passw.strip() if passw: del passw return "Not yet connected to " + self.conf.remote_radio_ip else: return "Not yet connected to %s -- Missing Password Here" % self.conf.remote_radio_ip def close(self): print('Closing Remote Control connection') self.closing = True t = f'QUIT\n' # Tell Remote Radio we are quitting self.RemoteCtlSend(t) self.RemoteCtlClose() return BaseHardware.close(self) def RemoteCtlClose(self): if self.remote_ctl_socket: self.remote_ctl_socket.close() else: print(' Remote Control TCP socket already closed') self.remote_ctl_socket = None self.remote_ctl_connected = False QS.stop_control_head_remote_sound() self.app.main_frame.SetConfigText("Disconnected from remote radio " + self.conf.remote_radio_ip) self.first_heartbeat = True def RemoteCtlConnect(self): if self.remote_ctl_connected: return True if not self.remote_ctl_socket: self.remote_ctl_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.remote_ctl_socket.setsockopt(socket.IPPROTO_IP, socket.IP_TOS, 184) # DSCP "Expedite" (46) if DEBUG: print("Default timeout for remote_ctl_socket = ", self.remote_ctl_socket.gettimeout()) self.remote_ctl_socket.settimeout(0.1) # Allow some time for connection response if DEBUG: print("Our set timeout for remote_ctl_socket = ", self.remote_ctl_socket.gettimeout()) try: self.remote_ctl_socket.connect((self.remote_radio_ip, self.remote_ctl_base_port)) except OSError as err: if str(err).startswith('[WinError 10056]'): # Connected, in spite of "error" -- # This can occur in Windows; Windows networking infrastructure continues attempting to connect, # even after the connect() call times out (as set in settimeout(), above). # If Windows connects after the timeout, then, the next time we call connect(), # it returns WinError 10056, "A connect request was made on an already connected socket". # Let's accept this "error" as success! if DEBUG: print('connect() returned WinError 10056; Windows already connected! Good!') pass else: # Not yet connected; errors may be expected/normal, or unexpected. if str(err).startswith('time'): # Timeout "error" is normal when we are waiting for Remote Radio server to become available. if DEBUG: print('connect() returned timeout; still waiting for remote radio server') elif str(err).startswith('[WinError 10022]'): # This can occur in Windows; Windows networking infrastructure continues attempting to connect, # even after the connect() call times out (as set in settimeout(), above). # If, by the next time we call connect(), it has not yet connected, but is still trying to do so # as a continuation of the prior call to connect(), it returns WinError 10022, # "An invalid argument was supplied", which is a little misleading, but okay for us. # We have not yet connected, but this is an "expected behavior". if DEBUG: print('connect() returned WinError 10022; invalid argument; still waiting for remote radio server') elif str(err).startswith('[Errno 103]'): # This can occur in Linux; still attempting to connect if DEBUG: print('connect() returned Errno 103; software connection abort; still waiting for remote radio server') elif str(err).startswith('[Errno 111]'): # This can occur in Linux; still attempting to connect if DEBUG: print('connect() returned Errno 111; connection refused; still waiting for remote radio server') else: # Unexpected error. Print error info, regardless of DEBUG status. print("Remote Control socket.connect() error: {0}".format(err)) return False # Failure to connect self.remote_ctl_connected = True self.remote_ctl_socket.settimeout(0.0) # Now that we're connected, don't wait if nothing there if DEBUG: print("Remote Control connected") self.app.main_frame.SetConfigText("Connecting to remote radio " + self.conf.remote_radio_ip) # We have a TCP connection, and the remote will send a challenge token. If we give a valid response # we will receive "TOKEN_OK" and we can start communication. return True # Success def ChangeFrequency(self, tune, vfo, source='', band='', event=None): # TODO: Try to get any modifications of freq or vfo from remote Quisk (?) t = f'FREQ;{tune};{vfo};{source};{band};{self.app.rxFreq};{self.VarDecimGetIndex()}\n' #print (t) self.RemoteCtlSend(t) #BMC if DEBUG: print('Change', source, tune, vfo, band) return tune, vfo def OnSpot(self, level): pass def SendCwDits(self): ts = time.time() # Use CW key to start/stop stream of dit phrases key_down = QS.is_cwkey_down() if not key_down: self.key_was_down = False else: if not self.key_was_down: # Leading edge detector for key down self.key_was_down = True if not self.send_cw_dits: self.send_cw_dits = True # Start sending dits self.cw_phrase_start_ts = ts # Beginning of phrase self.cw_test_next_ts = ts self.cw_test_next_msec = 0 # Start phrase at 0 msec self.num_dits_cur_count = 0 else: self.send_cw_dits = False # Stop sending dits if self.send_cw_dits and ts >= self.cw_test_next_ts: # Send one on/off pair of CW commands (with msec timestamps since start of CW phrase) t = f'CW;1;{self.cw_test_next_msec}\n' self.RemoteCtlSend(t) self.cw_test_next_msec += self.dit_width t = f'CW;0;{self.cw_test_next_msec}\n' self.RemoteCtlSend(t) self.cw_test_next_msec += self.space_width self.cw_test_next_ts = self.cw_phrase_start_ts + (float(self.cw_test_next_msec) / 1000) self.num_dits_cur_count += 1 if self.num_dits_cur_count >= self.num_dits_in_phrase: # Set up for next phrase self.cw_test_next_ts = ts + (float(self.phrase_gap) / 1000) self.cw_phrase_start_ts = self.cw_test_next_ts # Re-start beginning of phrase self.cw_test_next_msec = 0 # Start phrase at 0 msec self.num_dits_cur_count = 0 def ThreadPrinter(self, *args, **kw): # Call this to print from (possibly) the sound thread, which must not be slowed down by a print. # example: print (x, y, end=' ') ----> self.ThreadPrinter(x, y, end=' ') with self.thread_lock: # Call thread_lock only once; twice will deadlock. # If print request is from within sound thread, pass the print request to the GUI thread. if threading.current_thread().name == "QuiskSound": wx.CallAfter(print, *args, **kw) else: print(*args, **kw) def PollCwKey(self): # Called by the sound thread if DEBUG_CW_SEND_DITS: self.SendCwDits() return # Check Quisk key state, send to Remote Radio if change. # NOTE: Timestamps enable Remote Radio to overcome WiFi/network jitter ts = time.time() if not self.cw_phrase_end_ts: self.cw_phrase_end_ts = ts self.cw_poll_started_ts = ts key_down = QS.is_cwkey_down() if not self.cw_poll_started: # Detect Quisk startup with CW key down if ts - self.cw_poll_started_ts > 0.1: # Check for key down only within first 1/10 second of running self.cw_poll_started = True elif key_down == 1: # Quisk startup with key down t = f'Quisk is starting with CW key down! Tx is on, and Rx is blocked until you release CW key.' self.ThreadPrinter(t) #dlg = wx.MessageDialog(self.app.main_frame, t, "Quisk start, CW key down", style=wx.OK) #wx.CallAfter(dlg.ShowModal) self.cw_phrase_begin_ts = ts self.cw_poll_started = True if key_down != self.cw_keydown: if key_down == 1 and (ts - self.cw_phrase_end_ts) > self.cw_phrase_break_duration_secs: # First CW key-down since a while ago, re-start timestamp sequence for new CW phrase self.cw_phrase_begin_ts = ts cw_event_ts_msecs = int((ts - self.cw_phrase_begin_ts) * 1000) # float secs to int msecs self.cw_keydown = key_down t = f'CW;{key_down};{cw_event_ts_msecs}\n' self.RemoteCtlSend(t) self.cw_phrase_end_ts = ts # End-of-cw-phrase-detection if DEBUG_CW_JITTER: self.ThreadPrinter(f'{ts:10.4f} {key_down}, {cw_event_ts_msecs}') def HeartBeat(self): # Called at about 10 Hz by the main if self.closing: # Don't try to connect if we are closing return ts = time.time() if (ts - self.remote_ctl_timestamp) > 1.0 or self.first_heartbeat: self.remote_ctl_timestamp = ts if self.remote_ctl_connected: # Send keep-alive heartbeat command t = f'HEARTBEAT\n' self.RemoteCtlSend(t) else: # Else continually try to connect if DEBUG: print('Heartbeat Connect Attempt') self.RemoteCtlConnect() self.first_heartbeat = False self.RemoteCtlRead() def RemoteCtlSend(self, text): # RemoteCtlSend() may be called from sound thread or GUI thread! # self.thread lock (also used in self.ThreadPrinter) protects against thread collisions. if not self.remote_ctl_connected: if DEBUG: self.ThreadPrinter('Cannot send if not TCP connected:', text) return if DEBUG: self.ThreadPrinter('Send: ', text, end=' ') with self.thread_lock: # Do not call ThreadPrinter() from another thread lock! try: self.remote_ctl_socket.sendall(text.encode('utf-8', errors='ignore')) except OSError as err: errtxt = err pass else: return self.ThreadPrinter("Closing remote control socket; error in RemoteCtlSend(): {0}".format(errtxt)) self.RemoteCtlClose() def GetSmeter(self): return self.smeter_text def RemoteCtlRead(self): if not self.remote_ctl_connected: return try: # Read any data from the socket text = self.remote_ctl_socket.recv(1024).decode('utf-8', errors='replace') except socket.timeout: # This does not work pass except socket.error: # Nothing to read pass else: # We got some characters self.received += text while '\n' in self.received: # A complete response ending with newline is available reply, self.received = self.received.split('\n', 1) # Split off the reply, save any further characters reply = reply.strip() # Here is our reply if DEBUG: print('Rcvd: ', reply) if reply[0] in 'Qq': print('Closing Remote Control socket: Q (Quit) from remote radio') self.RemoteCtlClose() return elif reply[0] in 'Mm': # S-meter text from remote_radio self.smeter_text = reply[2:] #print ("Receive smeter", reply[2:]) elif reply[0:6] == "TOKEN;": passw = self.app.local_conf.globals.get("remote_radio_password", "") passw = passw.strip() if passw: passw = passw.encode('utf-8') H = hmac.new(passw, reply[6:].encode('utf-8'), 'sha3_256') del passw self.RemoteCtlSend("TOKEN;%s;%d\n" % (H.hexdigest(), self.app.data_width)) else: print ("Error: Missing password on control head") elif reply[0:8] == "TOKEN_OK": self.app.main_frame.SetConfigText("Connected to remote radio " + self.conf.remote_radio_ip) QS.start_control_head_remote_sound(self.remote_radio_ip, self.remote_radio_sound_port, self.graph_data_port) self.CommonInit() # Send initial parameters common to all radios self.RadioInit() # Send initial parameters peculiar to a given radio elif reply[0:9] == "TOKEN_BAD": self.app.main_frame.SetConfigText("Error: Remote radio %s: Security challenge failed" % self.conf.remote_radio_ip) elif reply[0:13] == "TOKEN_MISSING": self.app.main_frame.SetConfigText("Error: Remote radio %s has no password" % self.conf.remote_radio_ip) elif reply[0:9] == "HL2_TEMP;": setattr(self, "HL2_TEMP", reply[9:]) elif reply[:3] == 'ERR': print('Remote Radio returned ' + reply) else: print ("Control head received unrecognized command", reply) def CommonInit(self): # Send initial frequencies, band, sample rate, etc. to remote app = self.app # Frequency and decimation self.ChangeFrequency(app.txFreq + app.VFO, app.VFO, "NewDecim") # Band self.RemoteCtlSend("%s;1\n" % app.lastBand) # Mode btn = app.modeButns.GetSelectedButton() if btn: self.RemoteCtlSend("%s;%d\n" % (btn.idName, btn.GetIndex())) # Filter and adjustable bandwidth name = "Filter 6Slider" value = app.midiControls[name][0].button.slider_value self.RemoteCtlSend("%s;%d\n" % (name, value)) btn = app.filterButns.GetSelectedButton() if btn: self.RemoteCtlSend("%s;%d\n" % (btn.idName, btn.GetIndex())) # AGC and Squelch levels, split offset self.RemoteCtlSend("Split;0\n") btn = app.BtnAGC self.RemoteCtlSend("AGCSQLCH;%d;%d;%d;%d;%d\n" % (btn.slider_value_off, btn.slider_value_on, app.levelSquelch, app.levelSquelchSSB, app.split_offset)) idName = "SqlchSlider" value = app.midiControls[idName][0].button.slider_value self.RemoteCtlSend("%s;%d\n" % (idName, value)) # Spot slider idName = "SpotSlider" value = app.midiControls[idName][0].button.slider_value self.RemoteCtlSend("%s;%d\n" % (idName, value)) # Various buttons for idName in ("Mute", "NR2", "AGC", "Sqlch", "NB 1", "Notch", "Test 1", "Spot", "FDX", "PTT", "VOX"): self.RemoteCtlSend("%s;%d\n" % (idName, app.idName2Button[idName].GetIndex())) # Menus for menu in (app.NB_menu, app.split_menu, app.freedv_menu, app.smeter_menu): if menu: for nid in menu.id2data: menu_item = menu.FindItemById(nid) kind = menu_item.GetKind() if kind == wx.ITEM_RADIO: if menu_item.IsChecked(): self.RemoteCtlSend('MENU;%s;%s;1\n' % (menu.menu_name, menu_item.GetItemLabelText())) elif kind == wx.ITEM_CHECK: checked = menu_item.IsChecked() self.RemoteCtlSend('MENU;%s;%s;%d\n' % (menu.menu_name, menu_item.GetItemLabelText(), int(checked)))