diff --git a/config.template b/config.template index eef3e68..36de8a4 100644 --- a/config.template +++ b/config.template @@ -299,6 +299,7 @@ time = # using Hamlib rig control will monitor and alert on channel use enabled = False rigControlServerAddress = localhost:4532 +dxspotter_enabled = True # device interface to send the message to sigWatchBroadcastInterface = 1 # broadcast channel can also be a comma separated list of channels diff --git a/mesh_bot.py b/mesh_bot.py index 308a5ca..85c5aa2 100755 --- a/mesh_bot.py +++ b/mesh_bot.py @@ -50,6 +50,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "cqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "cqcqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "dopewars": lambda: handleDopeWars(message, message_from_id, deviceID), + "dx": lambda: handledxcluster(message, message_from_id, deviceID), "ea": lambda: handle_emergency_alerts(message, message_from_id, deviceID), "echo": lambda: handle_echo(message, message_from_id, deviceID, isDM, channel_number), "ealert": lambda: handle_emergency_alerts(message, message_from_id, deviceID), diff --git a/modules/README.md b/modules/README.md index d7ef2ee..539192f 100644 --- a/modules/README.md +++ b/modules/README.md @@ -875,4 +875,62 @@ bbslink_enabled = True bbslink_whitelist = # list of whitelisted nodes numbers ex: 2813308004,4258675309 empty list allows all ``` +# DX Spotter Module + +The DX Spotter module allows you to fetch and display recent DX cluster spots from [spothole.app](https://spothole.app) directly in your mesh-bot. + +## Command + +| Command | Description | +|---------|------------------------------| +| `dx` | Show recent DX cluster spots | + +## Usage + +Send a message to the bot containing the `dx` command. You can add filters to narrow down the results: + +- **Basic usage:** + ``` + dx + ``` + Returns the latest DX spots. + +- **With filters:** + ``` + dx band=20m mode=SSB + dx xota=WWFF + dx by=K7MHI + ``` + - `band=`: Filter by band (e.g., 20m, 40m) + - `mode=`: Filter by mode (e.g., SSB, CW, FT8) + - `xota=`: Filter by source/group (e.g., WWFF, POTA, SOTA) + - `by=`: Filter by callsign of the spotter + +## Example Output + +``` +K7ABC @14.074 MHz FT8 WWFF KFF-1234 by:N0CALL CN87 Some comment +W1XYZ @7.030 MHz CW SOTA W7W/WE-001 by:K7MHI CN88 +``` + +- Each line shows: + `DX_CALL @FREQUENCY MODE GROUP GROUP_REF by:SPOTTER_CALL SPOTTER_GRID COMMENT` + +## Notes + +- Returns up to 4 of the most recent spots matching your filters. +- Data is fetched from [spothole.app](https://spothole.app/). +- If no spots are found, you’ll see: + `No DX spots found.` + +## Configuration + +No additional configuration is required. The module is enabled if present in your `modules/` directory. + +--- + +**Written for Meshtastic mesh-bot by K7MHI Kelly Keeton 2025** + + + Happy meshing! \ No newline at end of file diff --git a/modules/dxspot.py b/modules/dxspot.py new file mode 100644 index 0000000..363b930 --- /dev/null +++ b/modules/dxspot.py @@ -0,0 +1,112 @@ +#meshing-around modules/dxspot.py +import requests +import datetime +from modules.log import logger + +trap_list_dxspotter = ["dx"] + +def handledxcluster(message, nodeID, deviceID): + from modules.dxspot import get_spothole_spots + if "DX" in message.upper(): + logger.debug(f"System: DXSpotter: Device:{deviceID} Handler: DX Spot Request Received from Node {nodeID}") + band = None + mode = None + source = None + dx_call = None + parts = message.split() + for part in parts: + if part.lower().startswith("band="): + band = part.split("=")[1] + elif part.lower().startswith("mode="): + mode = part.split("=")[1] + elif part.lower().startswith("xota="): + source = part.split("=")[1] + elif part.lower().startswith("by="): + dx_call = part.split("=")[1] + # Build params dict + params = {} + if source: + params["source"] = source.upper() + if band: + params["band"] = band.lower() + if mode: + params["mode"] = mode.upper() + if dx_call: + params["dx_call"] = dx_call.upper() + + # Fetch spots + spots = get_spothole_spots(**params) + if spots: + response_lines = [] + for spot in spots[:5]: + callsign = spot.get('dx_call', spot.get('callsign', 'N/A')) + freq_hz = spot.get('freq', spot.get('frequency', None)) + frequency = f"{float(freq_hz)/1e6:.3f} MHz" if freq_hz else "N/A" + mode_val = spot.get('mode', 'N/A') + comment = spot.get('comment', '') + sig = spot.get('sig', '') + de_grid = spot.get('de_grid', '') + de_call = spot.get('de_call', '') + sig_ref_name = spot.get('sig_refs_names', [''])[0] if spot.get('sig_refs_names') else '' + line = f"{callsign} @{frequency} {mode_val} {sig} {sig_ref_name} by:{de_call} {de_grid} {comment}" + response_lines.append(line) + response = "\n".join(response_lines) + else: + response = "No DX spots found." + return response + return "Error: No DX command found." + +def get_spothole_spots(source=None, band=None, mode=None, date=None, dx_call=None, de_continent=None, de_location=None): + """ + Fetches spots from https://spothole.app/api/v1/spots with optional filters. + Returns a list of spot dicts. + """ + url = "https://spothole.app/api/v1/spots" + params = {} + + + # Add administrative filters if provided + qrt = False # Always fetch active spots + needs_sig = False # Always need spots wth a group ike Xota + limit = 4 + dedupe = True + + params["dedupe"] = str(dedupe).lower() + params["limit"] = limit + params["qrt"] = str(qrt).lower() + params["needs_sig"] = str(needs_sig).lower() + params["needs_sig_ref"] = 'true' + # Only get spots from last 9 hours + received_since_dt = datetime.datetime.utcnow() - datetime.timedelta(hours=9) + received_since = int(received_since_dt.timestamp()) + params["received_since"] = received_since + + # Add spot filters if provided + if de_continent: + params["de_continent"] = de_continent + if de_location: + params["de_location"] = de_location + if source: + params["source"] = source + if band: + params["band"] = band + if mode: + params["mode"] = mode + if dx_call: + params["dx_call"] = dx_call + if date: + # date should be a string in YYYY-MM-DD or datetime.date + if isinstance(date, datetime.date): + params["date"] = date.isoformat() + else: + params["date"] = date + + try: + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko"} + response = requests.get(url, params=params, headers=headers) + response.raise_for_status() + spots = response.json() + except Exception as e: + logger.debug(f"Error fetching spots: {e}") + spots = [] + return spots diff --git a/modules/settings.py b/modules/settings.py index 75c044b..4f7a6b6 100644 --- a/modules/settings.py +++ b/modules/settings.py @@ -385,6 +385,7 @@ try: # radio monitoring radio_detection_enabled = config['radioMon'].getboolean('enabled', False) + dxspotter_enabled = config['radioMon'].getboolean('dxspotter_enabled', True) # default True rigControlServerAddress = config['radioMon'].get('rigControlServerAddress', 'localhost:4532') # default localhost:4532 sigWatchBroadcastCh = config['radioMon'].get('sigWatchBroadcastCh', '2').split(',') # default Channel 2 sigWatchBroadcastInterface = config['radioMon'].getint('sigWatchBroadcastInterface', 1) # default interface 1 diff --git a/modules/system.py b/modules/system.py index c5b20af..47971fa 100644 --- a/modules/system.py +++ b/modules/system.py @@ -141,6 +141,11 @@ if dad_jokes_enabled: trap_list = trap_list + ("joke",) help_message = help_message + ", joke" +if dxspotter_enabled: + from modules.dxspot import handledxcluster + trap_list = trap_list + ("dx",) + help_message = help_message + ", dx" + # Wikipedia Search Configuration if wikipedia_enabled: from modules.wiki import * # from the spudgunman/meshing-around repo