Merge pull request #246 from SpudGunMan/copilot/support-js8call-integration

Add WSJT-X and JS8Call integration for forwarding digital mode messages to mesh network. Not fully tested Please test and let me know what needs changed
This commit is contained in:
Kelly
2025-10-28 12:49:00 -07:00
committed by GitHub
8 changed files with 562 additions and 8 deletions
+2
View File
@@ -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).
+28 -5
View File
@@ -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,17 +327,37 @@ 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.
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
+6
View File
@@ -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
+92 -1
View File
@@ -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
+339 -2
View File
@@ -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,4 +339,265 @@ async def voxMonitor():
except Exception as e:
logger.error(f"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"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("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
+10
View File
@@ -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)
+56
View File
@@ -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
+29
View File
@@ -421,6 +421,35 @@ 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"]))
# 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"]))