Files
meshing-around/modules/radio.py
SpudGunMan 4406f2b86f it SPEAKS
KittenML/KittenTTS
2025-10-29 20:52:14 -07:00

637 lines
24 KiB
Python

# meshing around with hamlib as a source for info to send to mesh network
# detect signal strength and frequency of active channel if appears to be in use send to mesh network
# depends on rigctld running externally as a network service
# also can use VOX detection with a microphone and vosk speech to text to send voice messages to mesh network
# requires vosk and sounddevice python modules. will auto download needed. more from https://alphacephei.com/vosk/models and unpack
# 2025 Kelly Keeton K7MHI
# WSJT-X and JS8Call UDP Monitoring
# Based on WSJT-X UDP protocol specification
# Reference: https://github.com/ckuhtz/ham/blob/main/mcast/recv_decode.py
import asyncio
import socket
import struct
import json
from modules.log import logger
# verbose debug logging for trap words function
debugVoxTmsg = False
from modules.settings import (
radio_detection_enabled,
rigControlServerAddress,
signalDetectionThreshold,
signalHoldTime,
signalCooldown,
signalCycleLimit,
voxDetectionEnabled,
useLocalVoxModel,
localVoxModelPath,
voxLanguage,
voxInputDevice,
voxTrapList,
voxOnTrapList,
voxEnableCmd,
ERROR_FETCHING_DATA,
meshagesTTS,
)
# module global variables
previousStrength = -40
signalCycle = 0
FREQ_NAME_MAP = {
462562500: "GRMS CH1",
462587500: "GRMS CH2",
462612500: "GRMS CH3",
462637500: "GRMS CH4",
462662500: "GRMS CH5",
462687500: "GRMS CH6",
462712500: "GRMS CH7",
467562500: "GRMS CH8",
467587500: "GRMS CH9",
467612500: "GRMS CH10",
467637500: "GRMS CH11",
467662500: "GRMS CH12",
467687500: "GRMS CH13",
467712500: "GRMS CH14",
467737500: "GRMS CH15",
462550000: "GRMS CH16",
462575000: "GMRS CH17",
462600000: "GMRS CH18",
462625000: "GMRS CH19",
462675000: "GMRS CH20",
462670000: "GMRS CH21",
462725000: "GMRS CH22",
462725500: "GMRS CH23",
467575000: "GMRS CH24",
467600000: "GMRS CH25",
467625000: "GMRS CH26",
467650000: "GMRS CH27",
467675000: "GMRS CH28",
467700000: "FRS CH1",
462650000: "FRS CH5",
462700000: "FRS CH7",
462737500: "FRS CH16",
146520000: "2M Simplex Calling",
446000000: "70cm Simplex Calling",
156800000: "Marine CH16",
# Add more as needed
}
# --- WSJT-X and JS8Call Settings Initialization ---
wsjtxMsgQueue = [] # Queue for WSJT-X detected messages
js8callMsgQueue = [] # Queue for JS8Call detected messages
wsjtx_enabled = False
js8call_enabled = False
wsjtx_udp_port = 2237
js8call_udp_port = 2442
watched_callsigns = []
wsjtx_udp_address = '127.0.0.1'
js8call_tcp_address = '127.0.0.1'
js8call_tcp_port = 2442
# WSJT-X UDP Protocol Message Types
WSJTX_HEARTBEAT = 0
WSJTX_STATUS = 1
WSJTX_DECODE = 2
WSJTX_CLEAR = 3
WSJTX_REPLY = 4
WSJTX_QSO_LOGGED = 5
WSJTX_CLOSE = 6
WSJTX_REPLAY = 7
WSJTX_HALT_TX = 8
WSJTX_FREE_TEXT = 9
WSJTX_WSPR_DECODE = 10
WSJTX_LOCATION = 11
WSJTX_LOGGED_ADIF = 12
try:
from modules.settings import (
wsjtx_detection_enabled,
wsjtx_udp_server_address,
wsjtx_watched_callsigns,
js8call_detection_enabled,
js8call_server_address,
js8call_watched_callsigns
)
wsjtx_enabled = wsjtx_detection_enabled
js8call_enabled = js8call_detection_enabled
# Use a local list to collect callsigns before assigning to watched_callsigns
callsigns = []
if wsjtx_enabled:
if ':' in wsjtx_udp_server_address:
wsjtx_udp_address, port_str = wsjtx_udp_server_address.split(':')
wsjtx_udp_port = int(port_str)
if wsjtx_watched_callsigns:
callsigns.extend([cs.strip() for cs in wsjtx_watched_callsigns.split(',') if cs.strip()])
if js8call_enabled:
if ':' in js8call_server_address:
js8call_tcp_address, port_str = js8call_server_address.split(':')
js8call_tcp_port = int(port_str)
if js8call_watched_callsigns:
callsigns.extend([cs.strip() for cs in js8call_watched_callsigns.split(',') if cs.strip()])
# Clean up and deduplicate callsigns, uppercase for matching
watched_callsigns = list({cs.upper() for cs in callsigns})
except ImportError:
logger.debug("System: RadioMon: WSJT-X/JS8Call settings not configured")
except Exception as e:
logger.warning(f"System: RadioMon: Error loading WSJT-X/JS8Call settings: {e}")
if radio_detection_enabled:
# used by hamlib detection
import socket
if voxDetectionEnabled:
# methods available for trap word processing, these can be called by VOX detection when trap words are detected
from mesh_bot import tell_joke, handle_wxc, handle_moon, handle_sun, handle_riverFlow, handle_tide, handle_satpass
botMethods = {
"joke": tell_joke,
"weather": handle_wxc,
"moon": handle_moon,
"daylight": handle_sun,
"river": handle_riverFlow,
"tide": handle_tide,
"satellite": handle_satpass}
# module global variables
previousVoxState = False
voxHoldTime = signalHoldTime
try:
import sounddevice as sd # pip install sounddevice sudo apt install portaudio19-dev
from vosk import Model, KaldiRecognizer # pip install vosk
import json
q = asyncio.Queue(maxsize=32) # queue for audio data
if useLocalVoxModel:
voxModel = Model(lang=localVoxModelPath) # use built in model for specified language
else:
voxModel = Model(lang=voxLanguage) # use built in model for specified language
except Exception as e:
print(f"System: RadioMon: Error importing VOX dependencies: {e}")
print(f"To use VOX detection please install the vosk and sounddevice python modules")
print(f"pip install vosk sounddevice")
print(f"sounddevice needs pulseaudio, apt-get install portaudio19-dev")
voxDetectionEnabled = False
logger.error(f"System: RadioMon: VOX detection disabled due to import error")
if meshagesTTS:
try:
# TTS for meshages imports
logger.debug("System: RadioMon: Initializing TTS model for audible meshages")
import sounddevice as sd
from kittentts import KittenTTS
ttsModel = KittenTTS("KittenML/kitten-tts-nano-0.2")
available_voices = [
'expr-voice-2-m', 'expr-voice-2-f', 'expr-voice-3-m', 'expr-voice-3-f',
'expr-voice-4-m', 'expr-voice-4-f', 'expr-voice-5-m', 'expr-voice-5-f'
]
except Exception as e:
logger.error(f"To use Meshages TTS please review the radio.md documentation for setup instructions.")
meshagesTTS = False
async def generate_and_play_tts(text, voice, samplerate=24000):
"""Async: Generate speech and play audio."""
text = text.strip()
if not text:
return
try:
logger.debug(f"System: RadioMon: Generating TTS for text: {text} with voice: {voice}")
audio = await asyncio.to_thread(ttsModel.generate, text, voice=voice)
if audio is None or len(audio) == 0:
return
await asyncio.to_thread(sd.play, audio, samplerate)
await asyncio.to_thread(sd.wait)
del audio
except Exception as e:
logger.warning(f"System: RadioMon: Error in generate_and_play_tts: {e}")
def get_freq_common_name(freq):
freq = int(freq)
name = FREQ_NAME_MAP.get(freq)
if name:
return name
else:
# Return MHz if not found
return f"{freq/1000000} Mhz"
def get_hamlib(msg="f"):
# get data from rigctld server
if "socket" not in globals():
logger.warning("System: RadioMon: 'socket' module not imported. Hamlib disabled.")
return ERROR_FETCHING_DATA
try:
rigControlSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
rigControlSocket.settimeout(2)
rigControlSocket.connect((rigControlServerAddress.split(":")[0],int(rigControlServerAddress.split(":")[1])))
except Exception as e:
logger.error(f"System: RadioMon: Error connecting to rigctld: {e}")
return ERROR_FETCHING_DATA
try:
build_msg = f"{msg}\n"
MESSAGE = bytes(build_msg, "utf-8")
rigControlSocket.sendall(MESSAGE)
# Look for the response
data = rigControlSocket.recv(16)
rigControlSocket.close()
# strip newline and return
data = data.replace(b'\n',b'')
return data.decode("utf-8").rstrip()
except Exception as e:
logger.error(f"System: RadioMon: Error fetching data from rigctld: {e}")
return ERROR_FETCHING_DATA
def get_sig_strength():
strength = get_hamlib('l STRENGTH')
return strength
def checkVoxTrapWords(text):
try:
if not voxOnTrapList:
logger.debug(f"System: RadioMon: VOX detected: {text}")
return text
if text:
traps = [voxTrapList] if isinstance(voxTrapList, str) else voxTrapList
text_lower = text.lower()
for trap in traps:
trap_clean = trap.strip()
trap_lower = trap_clean.lower()
idx = text_lower.find(trap_lower)
if debugVoxTmsg:
logger.debug(f"System: RadioMon: VOX checking for trap word '{trap_lower}' in: '{text}' (index: {idx})")
if idx != -1:
new_text = text[idx + len(trap_clean):].strip()
if debugVoxTmsg:
logger.debug(f"System: RadioMon: VOX detected trap word '{trap_lower}' in: '{text}' (remaining: '{new_text}')")
new_words = new_text.split()
if voxEnableCmd:
for word in new_words:
if word in botMethods:
logger.info(f"System: RadioMon: VOX action '{word}' with '{new_text}'")
if word == "joke":
return botMethods[word](vox=True)
else:
return botMethods[word](None, None, None, vox=True)
logger.debug(f"System: RadioMon: VOX returning text after trap word '{trap_lower}': '{new_text}'")
return new_text
if debugVoxTmsg:
logger.debug(f"System: RadioMon: VOX no trap word found in: '{text}'")
return None
except Exception as e:
logger.debug(f"System: RadioMon: Error in checkVoxTrapWords: {e}")
return None
async def signalWatcher():
global previousStrength
global signalCycle
try:
signalStrength = int(get_sig_strength())
if signalStrength >= previousStrength and signalStrength > signalDetectionThreshold:
message = f"Detected {get_freq_common_name(get_hamlib('f'))} active. S-Meter:{signalStrength}dBm"
logger.debug(f"System: RadioMon: {message}. Waiting for {signalHoldTime} seconds")
previousStrength = signalStrength
signalCycle = 0
await asyncio.sleep(signalHoldTime)
return message
else:
signalCycle += 1
if signalCycle >= signalCycleLimit:
signalCycle = 0
previousStrength = -40
await asyncio.sleep(signalCooldown)
return None
except Exception as e:
signalStrength = -40
signalCycle = 0
previousStrength = -40
async def make_vox_callback(loop, q):
def vox_callback(indata, frames, time, status):
if status:
logger.warning(f"System: RadioMon: VOX input status: {status}")
try:
loop.call_soon_threadsafe(q.put_nowait, bytes(indata))
except asyncio.QueueFull:
# Drop the oldest item and add the new one
try:
q.get_nowait() # Remove oldest
except asyncio.QueueEmpty:
pass
try:
loop.call_soon_threadsafe(q.put_nowait, bytes(indata))
except asyncio.QueueFull:
# If still full, just drop this frame
logger.debug("System: RadioMon: VOX queue full, dropping audio frame")
except RuntimeError:
# Loop may be closed
pass
return vox_callback
async def voxMonitor():
global previousVoxState, voxMsgQueue
try:
model = voxModel
device_info = sd.query_devices(voxInputDevice, 'input')
samplerate = 16000
logger.debug(f"System: RadioMon: VOX monitor started on device {device_info['name']} with samplerate {samplerate} using trap words: {voxTrapList if voxOnTrapList else 'none'}")
rec = KaldiRecognizer(model, samplerate)
loop = asyncio.get_running_loop()
callback = await make_vox_callback(loop, q)
with sd.RawInputStream(
device=voxInputDevice,
samplerate=samplerate,
blocksize=4000,
dtype='int16',
channels=1,
callback=callback
):
while True:
data = await q.get()
if rec.AcceptWaveform(data):
result = rec.Result()
text = json.loads(result).get("text", "")
# process text
if text and text != 'huh':
result = checkVoxTrapWords(text)
if result:
# If result is a function return, handle it (send to mesh, log, etc.)
# If it's just text, handle as a normal message
voxMsgQueue.append(result)
await asyncio.sleep(0.1)
except Exception as e:
logger.warning(f"System: RadioMon: Error in VOX monitor: {e}")
def decode_wsjtx_packet(data):
"""Decode WSJT-X UDP packet according to the protocol specification"""
try:
# WSJT-X uses Qt's QDataStream format (big-endian)
magic = struct.unpack('>I', data[0:4])[0]
if magic != 0xADBCCBDA:
return None
schema_version = struct.unpack('>I', data[4:8])[0]
msg_type = struct.unpack('>I', data[8:12])[0]
offset = 12
# Helper to read Qt QString (4-byte length + UTF-8 data)
def read_qstring(data, offset):
if offset + 4 > len(data):
return "", offset
length = struct.unpack('>I', data[offset:offset+4])[0]
offset += 4
if length == 0xFFFFFFFF: # Null string
return "", offset
if offset + length > len(data):
return "", offset
text = data[offset:offset+length].decode('utf-8', errors='ignore')
return text, offset + length
# Decode DECODE message (type 2)
if msg_type == WSJTX_DECODE:
# Read fields according to WSJT-X protocol
wsjtx_id, offset = read_qstring(data, offset)
# Read other decode fields: new, time, snr, delta_time, delta_frequency, mode, message
if offset + 1 > len(data):
return None
new = struct.unpack('>?', data[offset:offset+1])[0]
offset += 1
if offset + 4 > len(data):
return None
time_val = struct.unpack('>I', data[offset:offset+4])[0]
offset += 4
if offset + 4 > len(data):
return None
snr = struct.unpack('>i', data[offset:offset+4])[0]
offset += 4
if offset + 8 > len(data):
return None
delta_time = struct.unpack('>d', data[offset:offset+8])[0]
offset += 8
if offset + 4 > len(data):
return None
delta_frequency = struct.unpack('>I', data[offset:offset+4])[0]
offset += 4
mode, offset = read_qstring(data, offset)
message, offset = read_qstring(data, offset)
return {
'type': 'decode',
'id': wsjtx_id,
'new': new,
'time': time_val,
'snr': snr,
'delta_time': delta_time,
'delta_frequency': delta_frequency,
'mode': mode,
'message': message
}
# Decode QSO_LOGGED message (type 5)
elif msg_type == WSJTX_QSO_LOGGED:
wsjtx_id, offset = read_qstring(data, offset)
# Read QSO logged fields
if offset + 8 > len(data):
return None
date_off = struct.unpack('>Q', data[offset:offset+8])[0]
offset += 8
if offset + 8 > len(data):
return None
time_off = struct.unpack('>Q', data[offset:offset+8])[0]
offset += 8
dx_call, offset = read_qstring(data, offset)
dx_grid, offset = read_qstring(data, offset)
return {
'type': 'qso_logged',
'id': wsjtx_id,
'dx_call': dx_call,
'dx_grid': dx_grid
}
return None
except Exception as e:
logger.debug(f"System: RadioMon: Error decoding WSJT-X packet: {e}")
return None
def check_callsign_match(message, callsigns):
"""Check if any watched callsign appears in the message
Uses word boundary matching to avoid false positives like matching
'K7' when looking for 'K7MHI'. Callsigns are expected to be
separated by spaces or be at the start/end of the message.
"""
if not callsigns:
return True # If no filter, accept all
message_upper = message.upper()
# Split message into words for exact matching
words = message_upper.split()
for callsign in callsigns:
callsign_upper = callsign.upper()
# Pre-compute patterns for portable/mobile suffixes
callsign_with_slash = callsign_upper + '/'
callsign_with_dash = callsign_upper + '-'
slash_callsign = '/' + callsign_upper
dash_callsign = '-' + callsign_upper
# Check if callsign appears as a complete word
if callsign_upper in words:
return True
# Check for callsigns in compound forms like "K7MHI/P" or "K7MHI-7"
for word in words:
if (word.startswith(callsign_with_slash) or
word.startswith(callsign_with_dash) or
word.endswith(slash_callsign) or
word.endswith(dash_callsign)):
return True
return False
async def wsjtxMonitor():
"""Monitor WSJT-X UDP broadcasts for decode messages"""
if not wsjtx_enabled:
logger.warning("System: RadioMon: WSJT-X monitoring called but not enabled")
return
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((wsjtx_udp_address, wsjtx_udp_port))
sock.setblocking(False)
logger.info(f"System: RadioMon: WSJT-X UDP listener started on {wsjtx_udp_address}:{wsjtx_udp_port}")
if watched_callsigns:
logger.info(f"System: RadioMon: Watching for callsigns: {', '.join(watched_callsigns)}")
while True:
try:
data, addr = sock.recvfrom(4096)
decoded = decode_wsjtx_packet(data)
if decoded and decoded['type'] == 'decode':
message = decoded['message']
mode = decoded['mode']
snr = decoded['snr']
# Check if message contains watched callsigns
if check_callsign_match(message, watched_callsigns):
msg_text = f"WSJT-X {mode}: {message} (SNR: {snr:+d}dB)"
logger.info(f"System: RadioMon: {msg_text}")
wsjtxMsgQueue.append(msg_text)
except BlockingIOError:
# No data available
await asyncio.sleep(0.1)
except Exception as e:
logger.debug(f"System: RadioMon: Error in WSJT-X monitor loop: {e}")
await asyncio.sleep(1)
except Exception as e:
logger.warning(f"System: RadioMon: Error starting WSJT-X monitor: {e}")
async def js8callMonitor():
"""Monitor JS8Call TCP API for messages"""
if not js8call_enabled:
logger.warning("System: RadioMon: JS8Call monitoring called but not enabled")
return
try:
logger.info(f"System: RadioMon: JS8Call TCP listener connecting to {js8call_tcp_address}:{js8call_tcp_port}")
if watched_callsigns:
logger.info(f"System: RadioMon: Watching for callsigns: {', '.join(watched_callsigns)}")
while True:
try:
# Connect to JS8Call TCP API
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((js8call_tcp_address, js8call_tcp_port))
sock.setblocking(False)
logger.info("System: RadioMon: Connected to JS8Call API")
buffer = ""
while True:
try:
data = sock.recv(4096)
if not data:
logger.warning("System: RadioMon: JS8Call connection closed")
break
buffer += data.decode('utf-8', errors='ignore')
# Process complete JSON messages (newline delimited)
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
if not line.strip():
continue
try:
msg = json.loads(line)
msg_type = msg.get('type', '')
# Handle RX.DIRECTED and RX.ACTIVITY messages
if msg_type in ['RX.DIRECTED', 'RX.ACTIVITY']:
params = msg.get('params', {})
text = params.get('TEXT', '')
from_call = params.get('FROM', '')
snr = params.get('SNR', 0)
if text and check_callsign_match(text, watched_callsigns):
msg_text = f"JS8Call from {from_call}: {text} (SNR: {snr:+d}dB)"
logger.info(f"System: RadioMon: {msg_text}")
js8callMsgQueue.append(msg_text)
except json.JSONDecodeError:
logger.debug(f"System: RadioMon: Invalid JSON from JS8Call: {line[:100]}")
except Exception as e:
logger.debug(f"System: RadioMon: Error processing JS8Call message: {e}")
except BlockingIOError:
await asyncio.sleep(0.1)
except socket.timeout:
await asyncio.sleep(0.1)
except Exception as e:
logger.debug(f"System: RadioMon: Error in JS8Call receive loop: {e}")
break
sock.close()
logger.warning("System: RadioMon: JS8Call connection lost, reconnecting in 5s...")
await asyncio.sleep(5)
except socket.timeout:
logger.warning("System: RadioMon: JS8Call connection timeout, retrying in 5s...")
await asyncio.sleep(5)
except Exception as e:
logger.warning(f"System: RadioMon: Error connecting to JS8Call: {e}")
await asyncio.sleep(10)
except Exception as e:
logger.warning(f"System: RadioMon: Error starting JS8Call monitor: {e}")
# end of file