import socket import structlog import helpers import threading class radio: """Controls a radio using rigctld. This class provides methods to interact with a radio using the rigctld server. It supports connecting, disconnecting, setting parameters (PTT, mode, frequency, bandwidth, RF level, tuner), retrieving radio parameters, starting and stopping the rigctld service, and handling VFO settings. """ log = structlog.get_logger("radio (rigctld)") def __init__(self, ctx): """Initializes the radio controller. Args: config (dict): Configuration dictionary. states (StateManager): State manager instance. hostname (str, optional): Hostname or IP address of the rigctld server. Defaults to "localhost". port (int, optional): Port number of the rigctld server. Defaults to 4532. timeout (int, optional): Timeout for socket operations in seconds. Defaults to 3. """ self.ctx = ctx self.hostname = self.ctx.config_manager.config['RIGCTLD']['ip'] self.port = self.ctx.config_manager.config['RIGCTLD']['port'] self.timeout = 3 self.rigctld_process = None self.max_connection_attempts = 60 self.restart_interval = 10 self.connection = None self.connected = False self.shutdown = False self.await_response = threading.Event() self.await_response.set() self.parameters = { 'frequency': '---', 'mode': '---', 'alc': '---', 'strength': '---', 'bandwidth': '---', 'rf': '---', 'ptt': False, # Initial PTT state is set to False, 'tuner': False, 'swr': '---', 'chk_vfo': False, 'vfo': '---', } # start rigctld... if self.ctx.config_manager.config["RADIO"]["control"] in ["rigctld_bundle"]: self.stop_service() self.start_service() # connect to radio self.connect() def connect(self): if self.shutdown: return if self.ctx.config_manager.config["RADIO"]["control"] not in ["rigctld", "rigctld_bundle"]: return for attempt in range(1, self.max_connection_attempts + 1): try: self.log.info( f"[RIGCTLD] Connection attempt {attempt}/{self.max_connection_attempts} " f"to {self.hostname}:{self.port}" ) self.connection = socket.create_connection( (self.hostname, self.port), timeout=self.timeout ) self.connection.settimeout(self.timeout) # allow rigctld to warm up threading.Event().wait(2) # success path self.connected = True self.ctx.state_manager.set_radio("radio_status", True) self.log.info(f"[RIGCTLD] Connected to rigctld at {self.hostname}:{self.port}") self.check_vfo() self.get_vfo() return except Exception as err: self.connected = False self.ctx.state_manager.set_radio("radio_status", False) self.log.warning(f"[RIGCTLD] Attempt {attempt} failed: {err}") # after every restart_interval failures, restart service if attempt % self.restart_interval == 0 and attempt < self.max_connection_attempts and self.ctx.config_manager.config["RADIO"]["control"] in ["rigctld_bundle"]: self.log.info( f"[RIGCTLD] Reached {attempt} failures, " f"restarting rigctld service." ) try: self.stop_service() self.start_service() except Exception as svc_err: self.log.error( f"[RIGCTLD] Failed to restart rigctld service: {svc_err}" ) # give service a moment to start threading.Event().wait(5) else: # brief pause before next attempt threading.Event().wait(1) # if still not connected after all attempts if not self.connected: self.log.error( f"[RIGCTLD] Could not establish connection after " f"{self.max_connection_attempts} attempts" ) def disconnect(self): """Disconnects from the rigctld server. This method disconnects from the rigctld server, updates the radio status, resets radio parameters, and closes the socket connection. """ self.shutdown = True self.connected = False if self.connection: self.connection.close() del self.connection self.connection = None self.ctx.state_manager.set_radio("radio_status", False) self.parameters = { 'frequency': '---', 'mode': '---', 'alc': '---', 'strength': '---', 'bandwidth': '---', 'rf': '---', 'ptt': False, # Initial PTT state is set to False, 'tuner': False, 'swr': '---', 'vfo': '---' } def send_command(self, command): """Sends a command to the rigctld server. This method sends a command to the rigctld server and waits for a response. It handles potential timeouts and other errors during communication. It uses a threading.Event to synchronize command sending and avoid concurrent access to the socket. Args: command (str): The command to send to rigctld. Returns: str or None: The response from rigctld, or None if an error occurred, the response contains 'RPRT', or the response contains 'None'. Raises: TimeoutError: If a timeout occurs while waiting for a response. """ if self.connected: # wait if we have another command awaiting its response... # we need to set a timeout for avoiding a blocking state if not self.await_response.wait(timeout=1): # Check if timeout occurred raise TimeoutError(f"[RIGCTLD] Timeout waiting for response from rigctld before sending command: [{command}]") try: self.await_response.clear() # Signal that a command is awaiting response if self.connection: self.connection.sendall(command.encode('utf-8') + b"\n") else: return None response = self.connection.recv(1024) self.await_response.set() # Signal that the response has been received stripped_result = response.decode('utf-8').strip() if 'RPRT' in stripped_result: return None if 'None' in stripped_result: return None return stripped_result except socket.timeout: self.log.warning(f"[RIGCTLD] Timeout waiting for response from rigctld: [{command}]") self.connected = False # Set connected to False if timeout occurs return None # Return None to indicate timeout except Exception as err: self.log.warning(f"[RIGCTLD] Error sending command [{command}] to rigctld: {err}") self.connected = False return None # Return None to indicate error finally: self.await_response.set() # Ensure await_response is always set in case of exceptions return "" def insert_vfo(self, command): """Inserts the VFO into rigctld commands if supported. This method modifies rigctld commands to include the VFO information if VFO support is enabled and the VFO is set. It takes a command string as input and returns the modified command string with the VFO inserted. Args: command (str): The rigctld command string. Returns: str: The modified command string with VFO inserted, or the original command string if VFO is not supported or not set. """ #self.get_vfo() if self.parameters['chk_vfo'] and self.parameters['vfo'] and self.parameters['vfo'] not in [None, False, 'err', 0]: return f"{command[:1].strip()} {self.parameters['vfo']} {command[1:].strip()}" return command def set_ptt(self, state): """Set the PTT (Push-to-Talk) state. Args: state (bool): True to enable PTT, False to disable. Returns: bool: True if the PTT state was set successfully, False otherwise. """ if self.connected: try: if state: command = 'T 1' else: command = 'T 0' command = self.insert_vfo(command) self.send_command(command) self.parameters['ptt'] = state # Update PTT state in parameters return True except Exception as err: self.log.warning(f"[RIGCTLD] Error setting PTT state: {err}") self.connected = False return False def set_mode(self, mode): """Set the mode. Args: mode (str): The mode to set. Returns: bool: True if the mode was set successfully, False otherwise. """ if self.connected: try: command = f"M {mode} 0" command = self.insert_vfo(command) self.send_command(command) self.parameters['mode'] = mode return True except Exception as err: self.log.warning(f"[RIGCTLD] Error setting mode: {err}") self.connected = False return False def set_frequency(self, frequency): """Set the frequency. Args: frequency (str): The frequency to set. Returns: bool: True if the frequency was set successfully, False otherwise. """ if self.connected: try: command = f"F {frequency}" command = self.insert_vfo(command) self.send_command(command) self.parameters['frequency'] = frequency return True except Exception as err: self.log.warning(f"[RIGCTLD] Error setting frequency: {err}") self.connected = False return False def set_bandwidth(self, bandwidth): """Set the bandwidth. Args: bandwidth (str): The bandwidth to set. Returns: bool: True if the bandwidth was set successfully, False otherwise. """ if self.connected: try: command = f"M {self.parameters['mode']} {bandwidth}" command = self.insert_vfo(command) self.send_command(command) self.parameters['bandwidth'] = bandwidth return True except Exception as err: self.log.warning(f"[RIGCTLD] Error setting bandwidth: {err}") self.connected = False return False def set_rf_level(self, rf): """Set the RF. Args: rf (str): The RF to set. Returns: bool: True if the RF was set successfully, False otherwise. """ if self.connected: try: command = f"L RFPOWER {rf/100}" #RF RFPOWER --> RFPOWER == IC705 command = self.insert_vfo(command) self.send_command(command) self.parameters['rf'] = rf return True except Exception as err: self.log.warning(f"[RIGCTLD] Error setting RF: {err}") self.connected = False return False def set_tuner(self, state): """Set the TUNER state. Args: state (bool): True to enable PTT, False to disable. Returns: bool: True if the PTT state was set successfully, False otherwise. """ if self.connected: try: if state: command = 'U TUNER 1' else: command = 'U TUNER 0' command = self.insert_vfo(command) self.send_command(command) self.parameters['tuner'] = state # Update PTT state in parameters return True except Exception as err: self.log.warning(f"[RIGCTLD] Error setting TUNER state: {err}") self.connected = False return False def get_tuner(self): """Set the TUNER state. Args: state (bool): True to enable PTT, False to disable. Returns: bool: True if the PTT state was set successfully, False otherwise. """ if self.connected: try: command = self.insert_vfo('u TUNER') result = self.send_command(command) state = result not in [None, ''] and int(result) == 1 self.parameters['tuner'] = state return True except Exception as err: self.log.warning(f"[RIGCTLD] Error getting TUNER state: {err}") self.get_vfo() return False def get_parameters(self): if not self.connected: self.connect() if self.connected: #self.check_vfo() self.get_vfo() self.get_frequency() self.get_mode_bandwidth() self.get_alc() self.get_strength() self.get_rf() self.get_tuner() self.get_swr() return self.parameters def dump_caps(self): """Dumps rigctld capabilities. This method sends the '\dump_caps' command to rigctld and prints the response. It is used for debugging and informational purposes. It handles potential errors during command execution. """ try: vfo_response = self.send_command(r'\dump_caps') print(vfo_response) except Exception as e: self.log.warning(f"Error getting dump_caps: {e}") def check_vfo(self): """Checks for VFO support. This method checks if the connected radio supports VFO by sending the '\chk_vfo' command to rigctld. It updates the 'chk_vfo' parameter accordingly and handles potential errors during the check. """ try: vfo_response = self.send_command(r'\chk_vfo') if vfo_response in [1, "1"]: self.parameters['chk_vfo'] = True else: self.parameters['chk_vfo'] = False except Exception as e: self.log.warning(f"Error getting chk_vfo: {e}") self.parameters['chk_vfo'] = False def get_vfo(self): """Gets the current VFO. This method retrieves the current VFO from the radio using the 'v' command if VFO support is enabled. It updates the 'vfo' parameter with the retrieved VFO or sets it to 'currVFO' if no specific VFO is returned. If VFO support is disabled, it sets 'vfo' to False. It handles potential errors during VFO retrieval. """ try: if self.parameters['chk_vfo']: vfo_response = self.send_command('v') if vfo_response not in [None, 'None', '']: self.parameters['vfo'] = vfo_response.strip('') else: self.parameters['vfo'] = 'currVFO' else: self.parameters['vfo'] = False except Exception as e: self.log.warning(f"Error getting vfo: {e}") self.parameters['vfo'] = 'err' def get_frequency(self): """Gets the current frequency from the radio. This method retrieves the current frequency from the radio using the 'f' command, with VFO support if enabled. It updates the 'frequency' parameter with the retrieved frequency or sets it to 'err' if an error occurs or no frequency is returned. It handles potential errors during frequency retrieval. """ try: command = self.insert_vfo('f') frequency_response = self.send_command(command) if frequency_response not in [None, '']: self.parameters['frequency'] = int(frequency_response) else: self.parameters['frequency'] = 'err' except Exception as e: self.log.warning(f"Error getting frequency: {e}") self.parameters['frequency'] = 'err' def get_mode_bandwidth(self): """Gets the current mode and bandwidth from the radio. This method retrieves the current mode and bandwidth from the radio using the 'm' command, with VFO support if enabled. It updates the 'mode' and 'bandwidth' parameters accordingly. It handles potential errors during retrieval, including ValueError if the response cannot be parsed correctly. """ try: command = self.insert_vfo('m') response = self.send_command(command) if response not in [None, '']: response = response.strip() mode, bandwidth = response.split('\n', 1) bandwidth = int(bandwidth) self.parameters['mode'] = mode self.parameters['bandwidth'] = bandwidth else: self.parameters['mode'] = 'err' self.parameters['bandwidth'] = 'err' except ValueError: self.parameters['mode'] = 'err' self.parameters['bandwidth'] = 'err' except Exception as e: self.log.warning(f"Error getting mode and bandwidth: {e}") self.parameters['mode'] = 'err' self.parameters['bandwidth'] = 'err' def get_alc(self): """Gets the ALC (Automatic Level Control) value. This method retrieves the ALC value from the radio using the 'l ALC' command, with VFO support if enabled. It updates the 'alc' parameter with the retrieved value or sets it to 'err' if an error occurs or no value is returned. It handles potential errors during ALC retrieval. """ try: command = self.insert_vfo('l ALC') alc_response = self.send_command(command) if alc_response not in [False, None, '', 'None', 0]: self.parameters['alc'] = float(alc_response) else: self.parameters['alc'] = 'err' except Exception as e: self.log.warning(f"Error getting ALC: {e}") self.parameters['alc'] = 'err' self.get_vfo() def get_strength(self): """Gets the signal strength. This method retrieves the signal strength from the radio using the 'l STRENGTH' command, with VFO support if enabled. It updates the 'strength' parameter with the retrieved value or sets it to 'err' if an error occurs or no value is returned. It handles potential errors during strength retrieval. """ try: command = self.insert_vfo('l STRENGTH') strength_response = self.send_command(command) if strength_response not in [None, '']: self.parameters['strength'] = int(strength_response) else: self.parameters['strength'] = 'err' except Exception as e: self.log.warning(f"Error getting strength: {e}") self.parameters['strength'] = 'err' self.get_vfo() def get_rf(self): """Gets the RF power level. This method retrieves the RF power level from the radio using the 'l RFPOWER' command, with VFO support if enabled. It updates the 'rf' parameter with the retrieved value (converted to integer percentage) or sets it to 'err' if an error occurs or no value is returned. It handles potential ValueErrors during conversion and other exceptions during retrieval. """ try: command = self.insert_vfo('l RFPOWER') rf_response = self.send_command(command) if rf_response not in [None, '']: self.parameters['rf'] = int(float(rf_response) * 100) else: self.parameters['rf'] = 'err' except ValueError: self.parameters['rf'] = 'err' except Exception as e: self.log.warning(f"Error getting RF power: {e}") self.parameters['rf'] = 'err' self.get_vfo() def get_swr(self): """Gets the SWR (Standing Wave Ratio) value. This method retrieves the SWR value from the radio using the 'l SWR' command, with VFO support if enabled. It updates the 'swr' parameter with the retrieved value or sets it to 'err' if an error occurs or no value is returned. It handles potential ValueErrors and other exceptions during retrieval. """ try: command = self.insert_vfo('l SWR') rf_response = self.send_command(command) if rf_response not in [None, '']: self.parameters['swr'] = rf_response else: self.parameters['swr'] = 'err' except ValueError: self.parameters['swr'] = 'err' except Exception as e: self.log.warning(f"Error getting SWR: {e}") self.parameters['swr'] = 'err' self.get_vfo() def start_service(self): """Starts the rigctld service. This method attempts to start the rigctld service using the configured parameters and any additional arguments. It searches for the rigctld binary in common locations, and if found, attempts to execute it. It handles potential errors during startup and logs informational and warning messages. """ binary_name = "rigctld" binary_paths = helpers.find_binary_paths(binary_name, search_system_wide=True) additional_args = self.format_rigctld_args() print(binary_paths) if binary_paths: for binary_path in binary_paths: try: self.log.info(f"Attempting to start rigctld using binary found at: {binary_path}") self.rigctld_process = helpers.kill_and_execute(binary_path, additional_args) self.log.info(f"Successfully executed rigctld", args=additional_args) return # Exit the function after successful execution except Exception as e: self.log.warning(f"Failed to start rigctld with binary at {binary_path}: {e}") # Log the error self.log.warning("Failed to start rigctld with all found binaries.", binaries=binary_paths) else: self.log.warning("Rigctld binary not found.") def stop_service(self): """Stops the rigctld service. This method stops the rigctld service if it was previously started by this class. It uses the helper function `helpers.kill_process` to terminate the rigctld process. """ if self.rigctld_process: self.log.info("Stopping rigctld service") # Log the action helpers.kill_process(self.rigctld_process) def format_rigctld_args(self): """Formats the arguments for starting rigctld. This method reads the configuration and constructs the command-line arguments for starting the rigctld process. It handles various settings like model ID, serial port parameters, PTT configuration, and custom arguments. Values defined as 'ignore', 0, or '0' in the configuration are skipped. The method returns a list of formatted arguments. Returns: list: A list of strings representing the formatted rigctld arguments. """ config = self.ctx.config_manager.config['RADIO'] config_rigctld = self.ctx.config_manager.config['RIGCTLD'] args = [] # Helper function to check if the value should be ignored def should_ignore(value): return value in ['ignore', 0] # Model ID, Serial Port, and Speed if not should_ignore(config.get('model_id')): args += ['-m', str(config['model_id'])] if not should_ignore(config.get('serial_port')): args += ['-r', config['serial_port']] if not should_ignore(config.get('serial_speed')): args += ['-s', str(config['serial_speed'])] # PTT Port and Type if not should_ignore(config.get('ptt_port')): args += ['-p', config['ptt_port']] if not should_ignore(config.get('ptt_type')): args += ['-P', config['ptt_type']] # Serial DCD and DTR if not should_ignore(config.get('serial_dcd')): args += ['-D', config['serial_dcd']] if not should_ignore(config.get('serial_dtr')): args += ['--set-conf', f'dtr_state={config["serial_dtr"]}'] # Handling Data Bits and Stop Bits if not should_ignore(config.get('data_bits')): args += ['--set-conf', f'data_bits={config["data_bits"]}'] if not should_ignore(config.get('stop_bits')): args += ['--set-conf', f'stop_bits={config["stop_bits"]}'] if self.ctx.config_manager.config['RIGCTLD']['enable_vfo']: args += ['--vfo'] # Fixme #rts_state # if not should_ignore(config.get('rts_state')): # args += ['--set-conf', f'stop_bits={config["rts_state"]}'] # Handle custom arguments for rigctld # Custom args are split via ' ' so python doesn't add extranaeous quotes on windows args += config_rigctld["arguments"].split(" ") print("Hamlib args ==>" + str(args)) return args