From 69fac4ba98785a888d82f3a1173923de3a1ed7b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:02:48 +0000 Subject: [PATCH 2/8] Add WSJT-X and JS8Call integration support Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com> --- config.template | 14 ++ mesh_bot.py | 6 + modules/radio.py | 305 ++++++++++++++++++++++++++++++++++++++++++++ modules/settings.py | 10 ++ modules/system.py | 56 ++++++++ 5 files changed, 391 insertions(+) diff --git a/config.template b/config.template index c705252..1c64f72 100644 --- a/config.template +++ b/config.template @@ -335,6 +335,20 @@ voxOnTrapList = True voxTrapList = chirpy voxEnableCmd = True +# WSJT-X UDP monitoring - listens for decode messages from WSJT-X, FT8/FT4/WSPR etc. +wsjtxDetectionEnabled = False +# UDP address and port where WSJT-X broadcasts (default: 127.0.0.1:2237) +wsjtxUdpServerAddress = 127.0.0.1:2237 +# Comma-separated list of callsigns to watch (empty = all callsigns) +wsjtxWatchedCallsigns = + +# JS8Call TCP monitoring - connects to JS8Call API for message forwarding +js8callDetectionEnabled = False +# TCP address and port where JS8Call API listens (default: 127.0.0.1:2442) +js8callServerAddress = 127.0.0.1:2442 +# Comma-separated list of callsigns to watch (empty = all callsigns) +js8callWatchedCallsigns = + [fileMon] filemon_enabled = False diff --git a/mesh_bot.py b/mesh_bot.py index 85becdf..c9a5229 100755 --- a/mesh_bot.py +++ b/mesh_bot.py @@ -2020,6 +2020,12 @@ async def main(): if my_settings.voxDetectionEnabled: tasks.append(asyncio.create_task(voxMonitor(), name="vox_detection")) + + if my_settings.wsjtx_detection_enabled: + tasks.append(asyncio.create_task(handleWsjtxWatcher(), name="wsjtx_monitor")) + + if my_settings.js8call_detection_enabled: + tasks.append(asyncio.create_task(handleJs8callWatcher(), name="js8call_monitor")) if my_settings.scheduler_enabled: from modules.scheduler import run_scheduler_loop, setup_scheduler diff --git a/modules/radio.py b/modules/radio.py index 4b1cb28..89350b6 100644 --- a/modules/radio.py +++ b/modules/radio.py @@ -263,4 +263,309 @@ async def voxMonitor(): except Exception as e: logger.error(f"RadioMon: Error in VOX monitor: {e}") +# 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 + +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 + +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 + + if wsjtx_enabled: + import socket + import struct + # Parse UDP address + if ':' in wsjtx_udp_server_address: + wsjtx_udp_address, port_str = wsjtx_udp_server_address.split(':') + wsjtx_udp_port = int(port_str) + watched_callsigns.extend(wsjtx_watched_callsigns.split(',') if wsjtx_watched_callsigns else []) + + if js8call_enabled: + import socket + import json + # Parse TCP address for JS8Call + if ':' in js8call_server_address: + js8call_tcp_address, port_str = js8call_server_address.split(':') + js8call_tcp_port = int(port_str) + watched_callsigns.extend(js8call_watched_callsigns.split(',') if js8call_watched_callsigns else []) + + # Clean up callsigns - remove whitespace + watched_callsigns = [cs.strip().upper() for cs in watched_callsigns if cs.strip()] + +except ImportError: + logger.debug("RadioMon: WSJT-X/JS8Call settings not configured") +except Exception as e: + logger.warning(f"RadioMon: Error loading WSJT-X/JS8Call settings: {e}") + +# 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 + +wsjtxMsgQueue = [] # Queue for WSJT-X detected messages +js8callMsgQueue = [] # Queue for JS8Call detected messages + +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"RadioMon: Error decoding WSJT-X packet: {e}") + return None + +def check_callsign_match(message, callsigns): + """Check if any watched callsign appears in the message""" + if not callsigns: + return True # If no filter, accept all + + message_upper = message.upper() + for callsign in callsigns: + if callsign in message_upper: + return True + return False + +async def wsjtxMonitor(): + """Monitor WSJT-X UDP broadcasts for decode messages""" + if not wsjtx_enabled: + logger.warning("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"RadioMon: WSJT-X UDP listener started on {wsjtx_udp_address}:{wsjtx_udp_port}") + if watched_callsigns: + logger.info(f"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"RadioMon: {msg_text}") + wsjtxMsgQueue.append(msg_text) + + except BlockingIOError: + # No data available + await asyncio.sleep(0.1) + except Exception as e: + logger.debug(f"RadioMon: Error in WSJT-X monitor loop: {e}") + await asyncio.sleep(1) + + except Exception as e: + logger.error(f"RadioMon: Error starting WSJT-X monitor: {e}") + +async def js8callMonitor(): + """Monitor JS8Call TCP API for messages""" + if not js8call_enabled: + logger.warning("RadioMon: JS8Call monitoring called but not enabled") + return + + try: + logger.info(f"RadioMon: JS8Call TCP listener connecting to {js8call_tcp_address}:{js8call_tcp_port}") + if watched_callsigns: + logger.info(f"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("RadioMon: Connected to JS8Call API") + + buffer = "" + while True: + try: + data = sock.recv(4096) + if not data: + logger.warning("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"RadioMon: {msg_text}") + js8callMsgQueue.append(msg_text) + + except json.JSONDecodeError: + logger.debug(f"RadioMon: Invalid JSON from JS8Call: {line[:100]}") + except Exception as e: + logger.debug(f"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"RadioMon: Error in JS8Call receive loop: {e}") + break + + sock.close() + logger.warning("RadioMon: JS8Call connection lost, reconnecting in 5s...") + await asyncio.sleep(5) + + except socket.timeout: + logger.warning("RadioMon: JS8Call connection timeout, retrying in 5s...") + await asyncio.sleep(5) + except Exception as e: + logger.warning(f"RadioMon: Error connecting to JS8Call: {e}") + await asyncio.sleep(10) + + except Exception as e: + logger.error(f"RadioMon: Error starting JS8Call monitor: {e}") + # end of file diff --git a/modules/settings.py b/modules/settings.py index ae89ae1..5cdc327 100644 --- a/modules/settings.py +++ b/modules/settings.py @@ -32,6 +32,8 @@ cmdHistory = [] # list to hold the command history for lheard and history comman msg_history = [] # list to hold the message history for the messages command max_bytes = 200 # Meshtastic has ~237 byte limit, use conservative 200 bytes for message content voxMsgQueue = [] # queue for VOX detected messages +wsjtxMsgQueue = [] # queue for WSJT-X detected messages +js8callMsgQueue = [] # queue for JS8Call detected messages # Game trackers surveyTracker = [] # Survey game tracker tictactoeTracker = [] # TicTacToe game tracker @@ -406,6 +408,14 @@ try: voxOnTrapList = config['radioMon'].getboolean('voxOnTrapList', False) # default False voxTrapList = config['radioMon'].get('voxTrapList', 'chirpy').split(',') # default chirpy voxEnableCmd = config['radioMon'].getboolean('voxEnableCmd', True) # default True + + # WSJT-X and JS8Call monitoring + wsjtx_detection_enabled = config['radioMon'].getboolean('wsjtxDetectionEnabled', False) # default WSJT-X detection disabled + wsjtx_udp_server_address = config['radioMon'].get('wsjtxUdpServerAddress', '127.0.0.1:2237') # default localhost:2237 + wsjtx_watched_callsigns = config['radioMon'].get('wsjtxWatchedCallsigns', '') # default empty (all callsigns) + js8call_detection_enabled = config['radioMon'].getboolean('js8callDetectionEnabled', False) # default JS8Call detection disabled + js8call_server_address = config['radioMon'].get('js8callServerAddress', '127.0.0.1:2442') # default localhost:2442 + js8call_watched_callsigns = config['radioMon'].get('js8callWatchedCallsigns', '') # default empty (all callsigns) # file monitor file_monitor_enabled = config['fileMon'].getboolean('filemon_enabled', False) diff --git a/modules/system.py b/modules/system.py index 9b0eb5b..3373263 100644 --- a/modules/system.py +++ b/modules/system.py @@ -2007,6 +2007,62 @@ async def handleFileWatcher(): await asyncio.sleep(1) pass +async def handleWsjtxWatcher(): + # monitor WSJT-X UDP broadcasts for decode messages + from modules.radio import wsjtxMsgQueue, wsjtxMonitor + from modules.settings import sigWatchBroadcastCh, sigWatchBroadcastInterface + + # Start the WSJT-X monitor task + monitor_task = asyncio.create_task(wsjtxMonitor()) + + while True: + if wsjtxMsgQueue: + msg = wsjtxMsgQueue.pop(0) + logger.debug(f"System: Detected message from WSJT-X: {msg}") + + # Broadcast to configured channels + if type(sigWatchBroadcastCh) is list: + for ch in sigWatchBroadcastCh: + if antiSpam and int(ch) != publicChannel: + send_message(msg, int(ch), 0, sigWatchBroadcastInterface) + else: + logger.warning(f"System: antiSpam prevented Alert from WSJT-X") + else: + if antiSpam and sigWatchBroadcastCh != publicChannel: + send_message(msg, int(sigWatchBroadcastCh), 0, sigWatchBroadcastInterface) + else: + logger.warning(f"System: antiSpam prevented Alert from WSJT-X") + + await asyncio.sleep(0.5) + +async def handleJs8callWatcher(): + # monitor JS8Call TCP API for messages + from modules.radio import js8callMsgQueue, js8callMonitor + from modules.settings import sigWatchBroadcastCh, sigWatchBroadcastInterface + + # Start the JS8Call monitor task + monitor_task = asyncio.create_task(js8callMonitor()) + + while True: + if js8callMsgQueue: + msg = js8callMsgQueue.pop(0) + logger.debug(f"System: Detected message from JS8Call: {msg}") + + # Broadcast to configured channels + if type(sigWatchBroadcastCh) is list: + for ch in sigWatchBroadcastCh: + if antiSpam and int(ch) != publicChannel: + send_message(msg, int(ch), 0, sigWatchBroadcastInterface) + else: + logger.warning(f"System: antiSpam prevented Alert from JS8Call") + else: + if antiSpam and sigWatchBroadcastCh != publicChannel: + send_message(msg, int(sigWatchBroadcastCh), 0, sigWatchBroadcastInterface) + else: + logger.warning(f"System: antiSpam prevented Alert from JS8Call") + + await asyncio.sleep(0.5) + async def retry_interface(nodeID): global retry_int1, retry_int2, retry_int3, retry_int4, retry_int5, retry_int6, retry_int7, retry_int8, retry_int9 global max_retry_count1, max_retry_count2, max_retry_count3, max_retry_count4, max_retry_count5, max_retry_count6, max_retry_count7, max_retry_count8, max_retry_count9 From 0f918ebccdb06c7eb53df3dae009a33fa646c0c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:05:36 +0000 Subject: [PATCH 3/8] Add documentation for WSJT-X and JS8Call integration Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com> --- README.md | 2 + modules/README.md | 93 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b7cecee..a22552d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ Mesh Bot is a feature-rich Python bot designed to enhance your [Meshtastic](http - **SNR RF Activity Alerts**: Monitor radio frequencies and receive alerts when high SNR (Signal-to-Noise Ratio) activity is detected. - **Hamlib Integration**: Use Hamlib (rigctld) to monitor the S meter on a connected radio. - **Speech-to-Text Broadcasting**: Convert received audio to text using [Vosk](https://alphacephei.com/vosk/models) and broadcast it to the mesh. +- **WSJT-X Integration**: Monitor WSJT-X (FT8, FT4, WSPR, etc.) decode messages and forward them to the mesh network with optional callsign filtering. +- **JS8Call Integration**: Monitor JS8Call messages and forward them to the mesh network with optional callsign filtering. ### Check-In / Check-Out & Asset Tracking - **Asset Tracking**: Maintain a check-in/check-out list for nodes or assets—ideal for accountability of people and equipment (e.g., Radio-Net, FEMA, trailhead groups). diff --git a/modules/README.md b/modules/README.md index f1a0632..980dbc3 100644 --- a/modules/README.md +++ b/modules/README.md @@ -218,11 +218,77 @@ Configure in `[fileMon]` section of `config.ini`. ## Radio Monitoring +The Radio Monitoring module provides several ways to integrate amateur radio software with the mesh network. + +### Hamlib Integration + | Command | Description | |--------------|-----------------------------------------------| | `radio` | Monitor radio SNR via Hamlib | -Configure in `[radioMon]` section of `config.ini`. +Monitors signal strength (S-meter) from a connected radio via Hamlib's `rigctld` daemon. When the signal exceeds a configured threshold, it broadcasts an alert to the mesh network with frequency and signal strength information. + +### WSJT-X Integration + +Monitors WSJT-X decode messages (FT8, FT4, WSPR, etc.) via UDP and forwards them to the mesh network. You can optionally filter by specific callsigns. + +**Features:** +- Listens to WSJT-X UDP broadcasts (default port 2237) +- Decodes WSJT-X protocol messages +- Filters by watched callsigns (or monitors all if no filter is set) +- Forwards decode messages with SNR information to configured mesh channels + +**Example Output:** +``` +WSJT-X FT8: CQ K7MHI CN87 (+12dB) +``` + +### JS8Call Integration + +Monitors JS8Call messages via TCP API and forwards them to the mesh network. You can optionally filter by specific callsigns. + +**Features:** +- Connects to JS8Call TCP API (default port 2442) +- Listens for directed and activity messages +- Filters by watched callsigns (or monitors all if no filter is set) +- Forwards messages with SNR information to configured mesh channels + +**Example Output:** +``` +JS8Call from W1ABC: HELLO WORLD (+8dB) +``` + +### Configuration + +Configure all radio monitoring features in the `[radioMon]` section of `config.ini`: + +```ini +[radioMon] +# Hamlib monitoring +enabled = False +rigControlServerAddress = localhost:4532 +signalDetectionThreshold = -10 + +# WSJT-X monitoring +wsjtxDetectionEnabled = False +wsjtxUdpServerAddress = 127.0.0.1:2237 +wsjtxWatchedCallsigns = K7MHI,W1AW + +# JS8Call monitoring +js8callDetectionEnabled = False +js8callServerAddress = 127.0.0.1:2442 +js8callWatchedCallsigns = K7MHI,W1AW + +# Broadcast settings (shared by all radio monitoring) +sigWatchBroadcastCh = 2 +sigWatchBroadcastInterface = 1 +``` + +**Configuration Notes:** +- Leave `wsjtxWatchedCallsigns` or `js8callWatchedCallsigns` empty to monitor all callsigns +- Callsigns are comma-separated, case-insensitive +- Both services can run simultaneously +- Messages are broadcast to the same channels as Hamlib alerts --- @@ -794,8 +860,11 @@ The bot will automatically extract and truncate content to fit Meshtastic's mess ### Radio Monitoring A module allowing a Hamlib compatible radio to connect to the bot. When functioning, it will message the configured channel with a message of in use. **Requires hamlib/rigctld to be running as a service.** +Additionally, the module supports monitoring WSJT-X and JS8Call for amateur radio digital modes. + ```ini [radioMon] +# Hamlib monitoring enabled = True rigControlServerAddress = localhost:4532 sigWatchBroadcastCh = 2 # channel to broadcast to can be 2,3 @@ -803,8 +872,30 @@ signalDetectionThreshold = -10 # minimum SNR as reported by radio via hamlib signalHoldTime = 10 # hold time for high SNR signalCooldown = 5 # the following are combined to reset the monitor signalCycleLimit = 5 + +# WSJT-X monitoring (FT8, FT4, WSPR, etc.) +# Monitors WSJT-X UDP broadcasts and forwards decode messages to mesh +wsjtxDetectionEnabled = False +wsjtxUdpServerAddress = 127.0.0.1:2237 # UDP address and port where WSJT-X broadcasts +wsjtxWatchedCallsigns = # Comma-separated list of callsigns to watch (empty = all) + +# JS8Call monitoring +# Connects to JS8Call TCP API and forwards messages to mesh +js8callDetectionEnabled = False +js8callServerAddress = 127.0.0.1:2442 # TCP address and port where JS8Call API listens +js8callWatchedCallsigns = # Comma-separated list of callsigns to watch (empty = all) + +# Broadcast settings (shared by Hamlib, WSJT-X, and JS8Call) +sigWatchBroadcastInterface = 1 ``` +**Setup Notes:** +- **WSJT-X**: Enable UDP Server in WSJT-X settings (File → Settings → Reporting → Enable UDP Server) +- **JS8Call**: Enable TCP Server in JS8Call settings (File → Settings → Reporting → Enable TCP Server API) +- Both services can run simultaneously +- Leave callsign filters empty to monitor all activity +- Callsigns are case-insensitive and comma-separated (e.g., `K7MHI,W1AW`) + ### File Monitoring Some dev notes for ideas of use From 49c88306a03494ee3c5a0f8233854b9d14f83545 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:08:15 +0000 Subject: [PATCH 4/8] Add tests and fix import issues for WSJT-X/JS8Call Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com> --- modules/radio.py | 8 ++++---- modules/test_bot.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/modules/radio.py b/modules/radio.py index 89350b6..6bc12c4 100644 --- a/modules/radio.py +++ b/modules/radio.py @@ -267,6 +267,10 @@ async def voxMonitor(): # Based on WSJT-X UDP protocol specification # Reference: https://github.com/ckuhtz/ham/blob/main/mcast/recv_decode.py +import socket +import struct +import json + wsjtx_enabled = False js8call_enabled = False wsjtx_udp_port = 2237 @@ -289,8 +293,6 @@ try: js8call_enabled = js8call_detection_enabled if wsjtx_enabled: - import socket - import struct # Parse UDP address if ':' in wsjtx_udp_server_address: wsjtx_udp_address, port_str = wsjtx_udp_server_address.split(':') @@ -298,8 +300,6 @@ try: watched_callsigns.extend(wsjtx_watched_callsigns.split(',') if wsjtx_watched_callsigns else []) if js8call_enabled: - import socket - import json # Parse TCP address for JS8Call if ':' in js8call_server_address: js8call_tcp_address, port_str = js8call_server_address.split(':') diff --git a/modules/test_bot.py b/modules/test_bot.py index 543edcd..75a1ecc 100644 --- a/modules/test_bot.py +++ b/modules/test_bot.py @@ -421,6 +421,29 @@ class TestBot(unittest.TestCase): flood_report = get_flood_openmeteo(lat, lon) self.assertIsInstance(flood_report, str) + def test_check_callsign_match(self): + # Test the callsign filtering function for WSJT-X/JS8Call + from radio import check_callsign_match + + # Test with empty filter (should match all) + self.assertTrue(check_callsign_match("CQ K7MHI CN87", [])) + + # Test exact match + self.assertTrue(check_callsign_match("CQ K7MHI CN87", ["K7MHI"])) + + # Test case insensitive match + self.assertTrue(check_callsign_match("CQ k7mhi CN87", ["K7MHI"])) + self.assertTrue(check_callsign_match("CQ K7MHI CN87", ["k7mhi"])) + + # Test no match + self.assertFalse(check_callsign_match("CQ W1AW FN31", ["K7MHI"])) + + # Test multiple callsigns + self.assertTrue(check_callsign_match("CQ W1AW FN31", ["K7MHI", "W1AW"])) + self.assertTrue(check_callsign_match("K7MHI DE W1AW", ["K7MHI", "W1AW"])) + + print("Callsign filtering tests passed") + From ee4f910d6e8bd111ded887625c3dac7b5e035e1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:11:58 +0000 Subject: [PATCH 5/8] Improve callsign matching to prevent false positives Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com> --- modules/radio.py | 20 ++++++++++++++++++-- modules/test_bot.py | 8 +++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/modules/radio.py b/modules/radio.py index 6bc12c4..f985286 100644 --- a/modules/radio.py +++ b/modules/radio.py @@ -436,14 +436,30 @@ def decode_wsjtx_packet(data): return None def check_callsign_match(message, callsigns): - """Check if any watched callsign appears in the message""" + """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: - if callsign in message_upper: + callsign_upper = callsign.upper() + # Check if callsign appears as a complete word + if callsign_upper in words: return True + # Also check for callsigns in compound forms like "K7MHI/P" or "K7MHI-7" + for word in words: + if word.startswith(callsign_upper + '/') or word.startswith(callsign_upper + '-'): + return True + if word.endswith('/' + callsign_upper) or word.endswith('-' + callsign_upper): + return True return False async def wsjtxMonitor(): diff --git a/modules/test_bot.py b/modules/test_bot.py index 75a1ecc..8e6f188 100644 --- a/modules/test_bot.py +++ b/modules/test_bot.py @@ -442,7 +442,13 @@ class TestBot(unittest.TestCase): self.assertTrue(check_callsign_match("CQ W1AW FN31", ["K7MHI", "W1AW"])) self.assertTrue(check_callsign_match("K7MHI DE W1AW", ["K7MHI", "W1AW"])) - print("Callsign filtering tests passed") + # Test portable/mobile suffixes + self.assertTrue(check_callsign_match("CQ K7MHI/P CN87", ["K7MHI"])) + self.assertTrue(check_callsign_match("W1AW-7", ["W1AW"])) + + # Test no false positives with partial matches + self.assertFalse(check_callsign_match("CQ K7MHIX CN87", ["K7MHI"])) + self.assertFalse(check_callsign_match("K7 TEST", ["K7MHI"])) From 2b0d7267b53992a4069b58be7fbce41c0286f0d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:14:01 +0000 Subject: [PATCH 6/8] Optimize callsign matching performance Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com> --- modules/radio.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/modules/radio.py b/modules/radio.py index f985286..4b8dd9e 100644 --- a/modules/radio.py +++ b/modules/radio.py @@ -451,15 +451,24 @@ def check_callsign_match(message, callsigns): 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 - # Also check for callsigns in compound forms like "K7MHI/P" or "K7MHI-7" + + # Check for callsigns in compound forms like "K7MHI/P" or "K7MHI-7" for word in words: - if word.startswith(callsign_upper + '/') or word.startswith(callsign_upper + '-'): - return True - if word.endswith('/' + callsign_upper) or word.endswith('-' + callsign_upper): + 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(): From 517c6cbf8262b4493848eb6e6884a6321e0bcfce Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Tue, 28 Oct 2025 12:37:04 -0700 Subject: [PATCH 7/8] Update config.template --- config.template | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/config.template b/config.template index 1c64f72..6fd8c29 100644 --- a/config.template +++ b/config.template @@ -309,14 +309,17 @@ interval = time = [radioMon] -# using Hamlib rig control will monitor and alert on channel use -enabled = False -rigControlServerAddress = localhost:4532 +# dx cluster `dx` command dxspotter_enabled = True -# device interface to send the message to + +# alerts in this module use the following interface and channel sigWatchBroadcastInterface = 1 # broadcast channel can also be a comma separated list of channels sigWatchBroadcastCh = 2 + +# using Hamlib rig control will monitor and alert on channel use +enabled = False +rigControlServerAddress = 127.0.0.1:4532 # minimum SNR as reported by radio via hamlib signalDetectionThreshold = -10 # hold time for high SNR @@ -324,15 +327,21 @@ signalHoldTime = 10 # the following are combined to reset the monitor signalCooldown = 5 signalCycleLimit = 5 -# enable VOX detection using default input + +# Enable VOX detection using default input voxDetectionEnabled = False # description to use in the alert message voxDescription = VOX + useLocalVoxModel = False +# default language for VOX detection voxLanguage = en-us +# sound.card input device to use for VOX detection, 'default' uses system default voxInputDevice = default +# "hey chirpy" voxOnTrapList = True voxTrapList = chirpy +# allow use of 'weather' and 'joke' commands via VOX voxEnableCmd = True # WSJT-X UDP monitoring - listens for decode messages from WSJT-X, FT8/FT4/WSPR etc. From 22ebc2bdbec85660faf9d09b62641ffc4568e6f1 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Tue, 28 Oct 2025 12:47:33 -0700 Subject: [PATCH 8/8] refactor --- modules/radio.py | 149 +++++++++++++++++++++++++---------------------- 1 file changed, 78 insertions(+), 71 deletions(-) diff --git a/modules/radio.py b/modules/radio.py index 4b8dd9e..f1ad5e5 100644 --- a/modules/radio.py +++ b/modules/radio.py @@ -3,10 +3,19 @@ # 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 -# 2024 Kelly Keeton K7MHI +# 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 + -from modules.log import logger import asyncio +import socket +import struct +import json +from modules.log import logger + from modules.settings import ( radio_detection_enabled, rigControlServerAddress, @@ -25,9 +34,76 @@ from modules.settings import ( ERROR_FETCHING_DATA ) +# module global variables + + # verbose debug logging for trap words function debugVoxTmsg = False +# --- 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("RadioMon: WSJT-X/JS8Call settings not configured") +except Exception as e: + logger.warning(f"RadioMon: Error loading WSJT-X/JS8Call settings: {e}") + if radio_detection_enabled: # used by hamlib detection @@ -263,75 +339,6 @@ async def voxMonitor(): except Exception as e: logger.error(f"RadioMon: Error in VOX monitor: {e}") -# 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 socket -import struct -import json - -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 - -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 - - if wsjtx_enabled: - # Parse UDP address - if ':' in wsjtx_udp_server_address: - wsjtx_udp_address, port_str = wsjtx_udp_server_address.split(':') - wsjtx_udp_port = int(port_str) - watched_callsigns.extend(wsjtx_watched_callsigns.split(',') if wsjtx_watched_callsigns else []) - - if js8call_enabled: - # Parse TCP address for JS8Call - if ':' in js8call_server_address: - js8call_tcp_address, port_str = js8call_server_address.split(':') - js8call_tcp_port = int(port_str) - watched_callsigns.extend(js8call_watched_callsigns.split(',') if js8call_watched_callsigns else []) - - # Clean up callsigns - remove whitespace - watched_callsigns = [cs.strip().upper() for cs in watched_callsigns if cs.strip()] - -except ImportError: - logger.debug("RadioMon: WSJT-X/JS8Call settings not configured") -except Exception as e: - logger.warning(f"RadioMon: Error loading WSJT-X/JS8Call settings: {e}") - -# 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 - -wsjtxMsgQueue = [] # Queue for WSJT-X detected messages -js8callMsgQueue = [] # Queue for JS8Call detected messages - def decode_wsjtx_packet(data): """Decode WSJT-X UDP packet according to the protocol specification""" try: