Added auto frequency sensing script for 991a to run direwolf with HF, VHF or UHF config files automatically.

This commit is contained in:
Craig McDaniel
2025-12-07 17:12:38 -06:00
parent 4522cc6bbf
commit f53cfc4e3d
4 changed files with 265 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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()