From f53cfc4e3db1326fa07169014c26fbed88415d5e Mon Sep 17 00:00:00 2001 From: Craig McDaniel Date: Sun, 7 Dec 2025 17:12:38 -0600 Subject: [PATCH] Added auto frequency sensing script for 991a to run direwolf with HF, VHF or UHF config files automatically. --- .../templates/ft991a-direwolf-hf.conf.j2 | 28 +++ .../templates/ft991a-direwolf-uhf.conf.j2 | 27 +++ ...lf.conf.j2 => ft991a-direwolf-vhf.conf.j2} | 0 .../templates/ft991a_monitor.py | 210 ++++++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 ansible/roles/scanner_ft991a/templates/ft991a-direwolf-hf.conf.j2 create mode 100644 ansible/roles/scanner_ft991a/templates/ft991a-direwolf-uhf.conf.j2 rename ansible/roles/scanner_ft991a/templates/{ft991a-direwolf.conf.j2 => ft991a-direwolf-vhf.conf.j2} (100%) create mode 100644 ansible/roles/scanner_ft991a/templates/ft991a_monitor.py diff --git a/ansible/roles/scanner_ft991a/templates/ft991a-direwolf-hf.conf.j2 b/ansible/roles/scanner_ft991a/templates/ft991a-direwolf-hf.conf.j2 new file mode 100644 index 0000000..804fe5e --- /dev/null +++ b/ansible/roles/scanner_ft991a/templates/ft991a-direwolf-hf.conf.j2 @@ -0,0 +1,28 @@ +# This file is managed by BusNet Ansible +# +# This is the Direwolf configuration for connecting Direwolf to the Yaesu FT-991a radio when it is +# plugged in via USB and tuned to an HF frequency. +# +# Note that only one direwolf will ever be running at the same time for this 991a radio, depending +# on what frequency it is tuned to. See /opt/busnet/direwolf/ft991a_monitor.py +# +# There us a udev rule which creates "/dev/radio/ft99a1-00" and "/dev/radio/ft991a-01" so that we +# do not have to worry about what USB port or tty number is assigned when this radio is connected +# to the computer. + +# We can reference this audio device by name already. +ADEVICE plughw:CODEC,0 + +# The custom udev rule makes this device available by name. +PTT /dev/radio/ft991a-01 RTS + +MYCALL K0BIT + +MODEM 300 1600:1800 D +#MODEM 300 1600:1800 + +# Enable FX.25 with fallback to regular AX.25 +FX25TX 1 + +KISSPORT 8005 +AGWPORT 8006 \ No newline at end of file diff --git a/ansible/roles/scanner_ft991a/templates/ft991a-direwolf-uhf.conf.j2 b/ansible/roles/scanner_ft991a/templates/ft991a-direwolf-uhf.conf.j2 new file mode 100644 index 0000000..1d0c66e --- /dev/null +++ b/ansible/roles/scanner_ft991a/templates/ft991a-direwolf-uhf.conf.j2 @@ -0,0 +1,27 @@ +# This file is managed by BusNet Ansible +# +# This is the Direwolf configuration for connecting Direwolf to the Yaesu FT-991a radio when it is +# plugged in via USB and tuned to an HF frequency. +# +# Note that only one direwolf will ever be running at the same time for this 991a radio, depending +# on what frequency it is tuned to. See /opt/busnet/direwolf/ft991a_monitor.py +# +# There us a udev rule which creates "/dev/radio/ft99a1-00" and "/dev/radio/ft991a-01" so that we +# do not have to worry about what USB port or tty number is assigned when this radio is connected +# to the computer. + +# We can reference this audio device by name already. +ADEVICE plughw:CODEC,0 + +# The custom udev rule makes this device available by name. +PTT /dev/radio/ft991a-01 RTS + +MYCALL K0BIT + +MODEM 9600 + +# Enable FX.25 with fallback to regular AX.25 +FX25TX 1 + +KISSPORT 8005 +AGWPORT 8006 \ No newline at end of file diff --git a/ansible/roles/scanner_ft991a/templates/ft991a-direwolf.conf.j2 b/ansible/roles/scanner_ft991a/templates/ft991a-direwolf-vhf.conf.j2 similarity index 100% rename from ansible/roles/scanner_ft991a/templates/ft991a-direwolf.conf.j2 rename to ansible/roles/scanner_ft991a/templates/ft991a-direwolf-vhf.conf.j2 diff --git a/ansible/roles/scanner_ft991a/templates/ft991a_monitor.py b/ansible/roles/scanner_ft991a/templates/ft991a_monitor.py new file mode 100644 index 0000000..a6f6c7f --- /dev/null +++ b/ansible/roles/scanner_ft991a/templates/ft991a_monitor.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 + +import socket +import subprocess +import time +import sys +import os +import signal +from typing import Optional, Dict + +# --- Configuration (Centralized) --- +DIREWOLF_PATH = '/usr/local/bin/direwolf' + +CONFIG = { + 'RIGCTLD_HOST': 'localhost', + 'RIGCTLD_PORT': 4000, # Port 4000 + 'RIGCTLD_TIMEOUT': 5.0, # Timeout + 'POLL_INTERVAL': 5.0, # Poll interval + + # !!! CONFIGURATION FILES !!! + 'CONFIG_FILES': { + 'HF': '/opt/busnet/direwolf/config/ft991a-hf.conf', + 'VHF': '/opt/busnet/direwolf/config/ft991a-vhf.conf', + 'UHF': '/opt/busnet/direwolf/config/ft991a-uhf.conf', + }, + + # Frequency Band Definitions (in Hz) + 'HF_MIN_FREQ': 1_800_000, + 'HF_MAX_FREQ': 30_000_000, + 'VHF_MAX_FREQ': 148_000_000, + 'UHF_MAX_FREQ': 450_000_000, + + 'DIREWOLF_ARGS': ['-t', '0', '-X', '1'], +} + + +class ModeSwitcherDaemon: + def __init__(self, config: Dict): + self.rigctld_host = config['RIGCTLD_HOST'] + self.rigctld_port = config['RIGCTLD_PORT'] + self.rigctld_timeout = config['RIGCTLD_TIMEOUT'] + self.poll_interval = config['POLL_INTERVAL'] + self.config_files = config['CONFIG_FILES'] + self.direwolf_path = DIREWOLF_PATH + self.direwolf_args = config['DIREWOLF_ARGS'] + + self.direwolf_process: Optional[subprocess.Popen] = None + self.current_band: Optional[str] = None + self._shutdown_flag = False + + self.hf_min = config['HF_MIN_FREQ'] + self.hf_max = config['HF_MAX_FREQ'] + self.vhf_max = config['VHF_MAX_FREQ'] + self.uhf_max = config['UHF_MAX_FREQ'] + + def _get_frequency_and_band(self) -> Optional[str]: + """ + Connects, queries, SHUTS DOWN WRITE (EOF), and reads response. + """ + temp_sock: Optional[socket.socket] = None + + try: + # 1. Connect + temp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + temp_sock.settimeout(self.rigctld_timeout) + # Disable Nagle's algorithm for speed + temp_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + temp_sock.connect((self.rigctld_host, self.rigctld_port)) + + # 2. Send Command (Matches your echo "+\get_freq") + # We explicitly send the backslash + newline. + temp_sock.sendall(b'+\\get_freq\n') + + # CRITICAL FIX: Send EOF (End of File) signal. + # This tells rigctld "I am done writing", forcing it to process and reply. + # This mimics the behavior of piping echo into netcat. + temp_sock.shutdown(socket.SHUT_WR) + + # 3. Read Response + full_response = temp_sock.recv(4096).decode('utf-8') + + # 4. Clean up + temp_sock.close() + + # 5. Parse + lines = full_response.strip().split('\n') + frequency_str = None + + for line in lines: + if line.startswith('Frequency:'): + frequency_str = line.split(':')[1].strip() + break + + if frequency_str is None or not frequency_str.isdigit(): + if full_response: + print(f"WARN: Invalid response format: {full_response.replace('\n', ' ')}", file=sys.stderr) + return None + + frequency = int(frequency_str) + + if frequency < self.hf_min: + return 'UNKNOWN' + elif frequency <= self.hf_max: + return 'HF' + elif frequency <= self.vhf_max: + return 'VHF' + elif frequency <= self.uhf_max: + return 'UHF' + else: + return 'UNKNOWN' + + except socket.timeout: + print(f"ERROR: rigctld response timed out ({self.rigctld_timeout}s).", file=sys.stderr) + return None + except socket.error as e: + print(f"ERROR: Socket error: {e}", file=sys.stderr) + return None + except Exception as e: + print(f"ERROR: Unexpected error: {e}", file=sys.stderr) + return None + finally: + if temp_sock: + try: + temp_sock.close() + except: + pass + + def _kill_direwolf_process(self): + if self.direwolf_process: + print("INFO: Shutting down Dire Wolf child process...") + try: + self.direwolf_process.terminate() + self.direwolf_process.wait(timeout=5) + print("INFO: Dire Wolf terminated successfully.") + except subprocess.TimeoutExpired: + print("WARN: Dire Wolf unresponsive, killing process.") + self.direwolf_process.kill() + self.direwolf_process.wait() + finally: + self.direwolf_process = None + self.current_band = None + + def _run_direwolf(self, target_band: str): + # Idempotency check + if self.direwolf_process and self.direwolf_process.poll() is None and self.current_band == target_band: + return + + # Cleanup dead process + if self.direwolf_process and self.direwolf_process.poll() is not None: + print(f"WARN: Dire Wolf process died unexpectedly (Exit Code: {self.direwolf_process.returncode}). Restarting for {target_band}.") + self.direwolf_process = None + + # Switch bands + if self.direwolf_process: + print(f"INFO: Switching Dire Wolf from {self.current_band} to {target_band}.") + self._kill_direwolf_process() + + try: + config_file = self.config_files[target_band] + print(f"INFO: Launching Dire Wolf with config: {config_file}") + + command_args = [self.direwolf_path] + self.direwolf_args + ['-c', config_file] + + self.direwolf_process = subprocess.Popen(command_args) + self.current_band = target_band + print(f"INFO: Dire Wolf started (PID: {self.direwolf_process.pid}).") + + except KeyError: + print(f"FATAL: Configuration file not defined for band '{target_band}'.", file=sys.stderr) + self._shutdown_flag = True + except Exception as e: + print(f"FATAL: Failed to launch Dire Wolf: {e}", file=sys.stderr) + self._shutdown_flag = True + + def handle_signal(self, signum, frame): + print(f"\nINFO: Signal {signum} received. Initiating graceful shutdown...") + self._shutdown_flag = True + + def run(self): + signal.signal(signal.SIGINT, self.handle_signal) + signal.signal(signal.SIGTERM, self.handle_signal) + + print(f"INFO: Starting Mode Switcher Daemon.") + + while not self._shutdown_flag: + target_band = self._get_frequency_and_band() + + if target_band is None: + pass + elif target_band == 'UNKNOWN': + print("WARN: Frequency is outside defined ranges. Dire Wolf is not managed.") + else: + self._run_direwolf(target_band) + + # Simple sleep loop to allow signal interrupt + for _ in range(int(self.poll_interval * 10)): + if self._shutdown_flag: break + time.sleep(0.1) + + self._kill_direwolf_process() + print("INFO: Script exited gracefully.") + + +if __name__ == '__main__': + if not os.path.exists(DIREWOLF_PATH): + print(f"FATAL: Dire Wolf executable not found at {DIREWOLF_PATH}.", file=sys.stderr) + sys.exit(1) + + daemon = ModeSwitcherDaemon(CONFIG) + daemon.run() \ No newline at end of file