diff --git a/README.md b/README.md index eed075e..479c540 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,12 @@ Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance yo - **SNR RF Activity Alerts**: Monitor a radio frequency and get alerts when high SNR RF activity is detected. - **Hamlib Integration**: Use Hamlib (rigctld) to watch the S meter on a connected radio. +### NOAA EAS Alerts +- **EAS Alerts via NOAA API**: Use an internet connected node to message Emergency Alerts from NOAA +- **EAS Alerts over the air**: Utalizing external tools to report EAS alerts offline over mesh + ### File Monitor Alerts -- **File Mon**: Monitor a flat/text file for changes, brodcast the contents of the message to mesh channel. This could be used to monitor NOAA OTA EAS System and offgrid send these alerts or any others to the mesh. Or to parse data from any other tools capable of writing information to file. +- **File Mon**: Monitor a flat/text file for changes, brodcast the contents of the message to mesh channel. ### Data Reporting - **HTML Generator**: Visualize bot traffic and data flows with a built-in HTML generator for [data reporting](logs/README.md). @@ -215,15 +219,30 @@ file_path = alert.txt broadcastCh = 2,4 ``` #### NOAA EAS -- [dsame3](https://github.com/jamieden/dsame3) - - this can be used with a rtl-sdr to capture alerts - - has a sample .ogg file for testing alerts - - TODO: fork this and have copy which will just dump the needed data right away? +To Alert on Mesh with the NOAA EAS API you can set the channels and enable, checks every 30min + +```ini +# EAS Alert Broadcast +wxAlertBroadcastEnabled = False +# EAS Alert Broadcast Channels +wxAlertBroadcastCh = 2,4 +``` + +To Monitor EAS with no internet connection see the following notes + - [EAS2Text](https://github.com/A-c0rN/EAS2Text) - depends on [multimon-ng](https://github.com/EliasOenal/multimon-ng) or [direwolf](https://github.com/wb2osz/direwolf) +- [dsame3](https://github.com/jamieden/dsame3) // recomend not using anything but the sample file for basic work + - this can be used with a rtl-sdr to capture alerts + - has a sample .ogg file for testing alerts +The following example shell command can pipe the data using [etc/eas_alert_parser.py](etc/eas_alert_parser.py) to alert.txt ```bash -sox -t ogg WXR-RWT.ogg -esigned-integer -b16 -r 22050 -t raw - | multimon-ng -a EAS -v 1 -t raw - > raw_NOAA_Alert.txt +sox -t ogg WXR-RWT.ogg -esigned-integer -b16 -r 22050 -t raw - | multimon-ng -a EAS -v 1 -t raw - | python eas_alert_parser.py +``` +The following example shell command will pipe rtl_sdr to alert.txt +```bash +rtl_fm -f 162425000 -s 22050 | multimon-ng -t raw -a EAS /dev/stdin | python eas_alert_parser.py ``` ### Scheduler @@ -281,8 +300,6 @@ For the Ollama LLM: ```sh pip install ollama -pip install langchain -pip install langchain-ollama pip install googlesearch-python ``` @@ -325,6 +342,7 @@ sudo apt-get install fonts-noto-color-emoji | `bbspost` | Posts a message to the public board or sends a DM(Mail) Examples: `bbspost $subject #message`, `bbspost @nodeNumber #message`, `bbspost @nodeShortName #message` | โœ… | | `bbsdelete` | Deletes a message. Example: `bbsdelete #4` | โœ… | | `bbsinfo` | Provides stats on BBS delivery and messages (sysop) | โœ… | +| `bbllink` | Links Bulletin Messages between BBS Systems | โœ… | ### Data Lookup | Command | Description | | @@ -373,5 +391,3 @@ I used ideas and snippets from other responder bots and want to call them out! ### Tools - **Node Backup Management**: [Node Slurper](https://github.com/SpudGunMan/node-slurper) - - diff --git a/config.template b/config.template index fc52ca6..484efb7 100644 --- a/config.template +++ b/config.template @@ -61,6 +61,8 @@ urlTimeout = 10 LogMessagesToFile = False # Logging of system messages to file SyslogToFile = True +# Number of log files to keep in days, 0 to keep all +log_backup_count = 32 [games] # if hop limit for the user exceeds this value, the message will be dropped @@ -107,6 +109,10 @@ UseMeteoWxAPI = False useMetric = False # repeaterList lookup location (rbook / artsci) repeaterLookup = rbook +# EAS Alert Broadcast +wxAlertBroadcastEnabled = False +# EAS Alert Broadcast Channels +wxAlertBroadcastCh = 2 # repeater module [repeater] diff --git a/etc/report_generator.py b/etc/report_generator.py index a3dd2ce..49a85df 100644 --- a/etc/report_generator.py +++ b/etc/report_generator.py @@ -372,7 +372,7 @@ def get_database_info(): elif 'bbsdm' in file: bbsdm = pickle.load(f) except Exception as e: - print(f"Error reading database file: {str(e)}") + print(f"Warning issue reading database file: {str(e)}") if 'lemonstand' in file: lemon_score = "no data" elif 'dopewar' in file: @@ -922,8 +922,8 @@ def generate_database_html(database_info): def main(): log_dir = LOG_PATH - today = datetime.now().strftime('%Y_%m_%d') - log_file = f'meshbot{today}.log' + today = datetime.now().strftime('%Y-%m-%d') + log_file = f'meshbot.log' log_path = os.path.join(log_dir, log_file) if not os.path.exists(log_path): diff --git a/etc/report_generator5.py b/etc/report_generator5.py index 1e9e8f9..e19ca0a 100644 --- a/etc/report_generator5.py +++ b/etc/report_generator5.py @@ -381,7 +381,7 @@ def get_database_info(): elif 'bbsdm' in file: bbsdm = pickle.load(f) except Exception as e: - print(f"Error reading database file: {str(e)}") + print(f"Warning issue reading database file: {str(e)}") if 'lemonstand' in file: lemon_score = "no data" elif 'dopewar' in file: @@ -1217,8 +1217,8 @@ def generate_database_html(database_info): def main(): # Log file log_dir = LOG_PATH - today = datetime.now().strftime('%Y_%m_%d') - log_file = f'meshbot{today}.log' + today = datetime.now().strftime('%Y-%m-%d') + log_file = f'meshbot.log' log_path = os.path.join(log_dir, log_file) if not os.path.exists(log_path): diff --git a/logs/README.md b/logs/README.md index 82e0be6..e18eccc 100644 --- a/logs/README.md +++ b/logs/README.md @@ -14,6 +14,8 @@ Logging messages to disk or 'Syslog' to disk uses the python native logging func LogMessagesToFile = False # Logging of system messages to file, needed for reporting engine SyslogToFile = True +# Number of log files to keep in days, 0 to keep all +log_backup_count = 32 ``` To change the stdout (what you see on the console) logging level (default is DEBUG) see the following example, line is in [../modules/log.py](../modules/log.py) diff --git a/mesh_bot.py b/mesh_bot.py index bc7691f..dca7e93 100755 --- a/mesh_bot.py +++ b/mesh_bot.py @@ -25,7 +25,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n # Command List default_commands = { - "ack": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM), + "ack": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "ask:": lambda: handle_llm(message_from_id, channel_number, deviceID, message, publicChannel), "askai": lambda: handle_llm(message_from_id, channel_number, deviceID, message, publicChannel), "bbslink": lambda: bbs_sync_posts(message, message_from_id, deviceID), @@ -37,9 +37,9 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "bbspost": lambda: handle_bbspost(message, message_from_id, deviceID), "bbsread": lambda: handle_bbsread(message), "blackjack": lambda: handleBlackJack(message, message_from_id, deviceID), - "cq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM), - "cqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM), - "cqcqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM), + "cq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), + "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), "cmd": lambda: help_message, "dopewars": lambda: handleDopeWars(message, message_from_id, deviceID), "games": lambda: gamesCmdList, @@ -54,15 +54,15 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "messages": lambda: handle_messages(message, deviceID, channel_number, msg_history, publicChannel, isDM), "moon": lambda: handle_moon(message_from_id, deviceID, channel_number), "motd": lambda: handle_motd(message, message_from_id, isDM), - "ping": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM), - "pinging": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM), + "ping": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), + "pinging": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "pong": lambda: "๐Ÿ“PING!!๐Ÿ›œ", "rlist": lambda: handle_repeaterQuery(message_from_id, deviceID, channel_number), "sitrep": lambda: handle_lheard(message, message_from_id, deviceID, isDM), "solar": lambda: drap_xray_conditions() + "\n" + solar_conditions(), "sun": lambda: handle_sun(message_from_id, deviceID, channel_number), - "test": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM), - "testing": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM), + "test": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), + "testing": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "tide": lambda: handle_tide(message_from_id, deviceID, channel_number), "videopoker": lambda: handleVideoPoker(message, message_from_id, deviceID), "whereami": lambda: handle_whereami(message_from_id, deviceID, channel_number), @@ -110,7 +110,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n time.sleep(responseDelay) return bot_response -def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM): +def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number): global multiPing if "?" in message and isDM: return message.split("?")[0].title() + " command returns SNR and RSSI, or hopcount from your message. Try adding e.g. @place or #tag" @@ -169,7 +169,7 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM): pingCount = -1 if pingCount > 1: - multiPingList.append({'message_from_id': message_from_id, 'count': pingCount + 1, 'type': type, 'deviceID': deviceID}) + multiPingList.append({'message_from_id': message_from_id, 'count': pingCount + 1, 'type': type, 'deviceID': deviceID, 'channel_number': channel_number}) msg = f"๐ŸšฆInitalizing {pingCount} auto-ping" return msg @@ -1039,6 +1039,8 @@ async def start_rx(): logger.debug(f"System: Radio Detection Enabled using rigctld at {rigControlServerAddress} brodcasting to channels: {sigWatchBroadcastCh} for {get_freq_common_name(get_hamlib('f'))}") if file_monitor_enabled: logger.debug(f"System: File Monitor Enabled for {file_monitor_file_path}, broadcasting to channels: {file_monitor_broadcastCh}") + if wxAlertBroadcastEnabled: + logger.debug(f"System: Weather Alert Broadcast Enabled on channels {wxAlertBroadcastChannel}") if scheduler_enabled: # Examples of using the scheduler, Times here are in 24hr format # https://schedule.readthedocs.io/en/stable/ diff --git a/modules/locationdata.py b/modules/locationdata.py index e14c454..ba4389f 100644 --- a/modules/locationdata.py +++ b/modules/locationdata.py @@ -325,11 +325,15 @@ def abbreviate_weather(row): return line -def getWeatherAlerts(lat=0, lon=0): +def getWeatherAlerts(lat=0, lon=0, useDefaultLatLon=False): # get weather alerts from NOAA limited to ALERT_COUNT with the total number of alerts found alerts = "" - if float(lat) == 0 and float(lon) == 0: + if float(lat) == 0 and float(lon) == 0 and not useDefaultLatLon: return NO_DATA_NOGPS + else: + if useDefaultLatLon: + lat = latitudeValue + lon = longitudeValue alert_url = "https://api.weather.gov/alerts/active.atom?point=" + str(lat) + "," + str(lon) #alert_url = "https://api.weather.gov/alerts/active.atom?area=WA" @@ -369,6 +373,23 @@ def getWeatherAlerts(lat=0, lon=0): data = "\n".join(alerts.split("\n")[:numWxAlerts]), alert_num return data +wxAlertCache = "" +def alertBrodcast(): + # get the latest weather alerts and broadcast them if there are any + global wxAlertCache + currentAlert = getWeatherAlerts(latitudeValue, longitudeValue) + + if currentAlert[0] == ERROR_FETCHING_DATA or currentAlert == NO_DATA_NOGPS or currentAlert == NO_ALERTS: + wxAlertCache = "" + return False + # broadcast the alerts send to wxBrodcastCh + elif currentAlert[0] != wxAlertCache: + logger.debug("Location:Broadcasting weather alerts") + wxAlertCache = currentAlert[0] + return currentAlert + + return False + def getActiveWeatherAlertsDetail(lat=0, lon=0): # get the latest details of weather alerts from NOAA alerts = "" diff --git a/modules/log.py b/modules/log.py index 28c4769..230ed7e 100644 --- a/modules/log.py +++ b/modules/log.py @@ -1,4 +1,5 @@ import logging +from logging.handlers import TimedRotatingFileHandler import re from datetime import datetime from modules.settings import * @@ -63,14 +64,14 @@ def format(self, record): if syslog_to_file: # Create file handler for logging to a file - file_handler_sys = logging.FileHandler('logs/meshbot{}.log'.format(today.strftime('%Y_%m_%d'))) + file_handler_sys = TimedRotatingFileHandler('logs/meshbot.log', when='midnight', backupCount=log_backup_count) file_handler_sys.setLevel(logging.DEBUG) # DEBUG used by default for system logs to disk file_handler_sys.setFormatter(plainFormatter(logFormat)) logger.addHandler(file_handler_sys) if log_messages_to_file: # Create file handler for logging to a file - file_handler = logging.FileHandler('logs/messages{}.log'.format(today.strftime('%Y_%m_%d'))) + file_handler = TimedRotatingFileHandler('logs/messages.log', when='midnight', backupCount=log_backup_count) file_handler.setLevel(logging.INFO) # INFO used for messages to disk file_handler.setFormatter(logging.Formatter(msgLogFormat)) msgLogger.addHandler(file_handler) \ No newline at end of file diff --git a/modules/settings.py b/modules/settings.py index b8858b7..a3f1e49 100644 --- a/modules/settings.py +++ b/modules/settings.py @@ -51,7 +51,7 @@ config.write(open(config_file, 'w')) if 'location' not in config: - config['location'] = {'enabled': 'True', 'lat': '48.50', 'lon': '-123.0', 'UseMeteoWxAPI': 'False', 'useMetric': 'False', 'NOAAforecastDuration': '4', 'NOAAalertCount': '2', 'NOAAalertsEnabled': 'True'} + config['location'] = {'enabled': 'True', 'lat': '48.50', 'lon': '-123.0', 'UseMeteoWxAPI': 'False', 'useMetric': 'False', 'NOAAforecastDuration': '4', 'NOAAalertCount': '2', 'NOAAalertsEnabled': 'True', 'wxAlertBroadcastEnabled': 'False', 'wxAlertBroadcastChannel': '2', 'repeaterLookup': 'rbook'} config.write(open(config_file, 'w')) if 'bbs' not in config: @@ -102,6 +102,7 @@ ignoreDefaultChannel = config['general'].getboolean('ignoreDefaultChannel', False) zuluTime = config['general'].getboolean('zuluTime', False) # aka 24 hour time log_messages_to_file = config['general'].getboolean('LogMessagesToFile', False) # default off + log_backup_count = config['general'].getint('LogBackupCount', 32) # default 32 days syslog_to_file = config['general'].getboolean('SyslogToFile', True) # default on urlTimeoutSeconds = config['general'].getint('urlTimeout', 10) # default 10 seconds store_forward_enabled = config['general'].getboolean('StoreForward', True) @@ -137,6 +138,13 @@ numWxAlerts = config['location'].getint('NOAAalertCount', 2) # default 2 alerts wxAlertsEnabled = config['location'].getboolean('NOAAalertsEnabled', True) # default True not enabled yet repeater_lookup = config['location'].get('repeaterLookup', 'rbook') # default repeater lookup source + wxAlertBroadcastEnabled = config['location'].getboolean('wxAlertBroadcastEnabled', False) # default False + # brodcast channel for weather alerts + wxAlertBroadcastChannel = config['location'].get('wxAlertBroadcastCh') + if ',' in wxAlertBroadcastChannel: + wxAlertBroadcastChannel = config['location'].get('wxAlertBroadcastCh').split(',') + else: + wxAlertBroadcastChannel = config['location'].getint('wxAlertBroadcastCh', 2) # default 2 # bbs bbs_enabled = config['bbs'].getboolean('enabled', False) diff --git a/modules/system.py b/modules/system.py index c6a5ee7..ab49c7b 100644 --- a/modules/system.py +++ b/modules/system.py @@ -15,7 +15,7 @@ help_message = "Bot CMD?:\n" asyncLoop = asyncio.new_event_loop() games_enabled = False -multiPingList = [{'message_from_id': 0, 'count': 0, 'type': '', 'deviceID': 0}] +multiPingList = [{'message_from_id': 0, 'count': 0, 'type': '', 'deviceID': 0, 'channel_number': 0}] # Ping Configuration @@ -573,15 +573,18 @@ def handleMultiPing(nodeID=0, deviceID=1): for i in range(len(mPlCpy)): message_id_from = mPlCpy[i]['message_from_id'] count = mPlCpy[i]['count'] - type = mPlCpy[i]['type'] + type = mPlCpy[i]['type'].strip() deviceID = mPlCpy[i]['deviceID'] + channel_number = mPlCpy[i]['channel_number'] - if count > 1 and deviceID == 1: + if count > 1: count -= 1 # update count in the list - multiPingList[i]['count'] = count + for i in range(len(multiPingList)): + if multiPingList[i]['message_from_id'] == message_id_from: + multiPingList[i]['count'] = count - send_message(f"๐Ÿ”‚{count} {type}", publicChannel, message_id_from, 1) + send_message(f"๐Ÿ”‚{count} {type}", channel_number, message_id_from, deviceID) if count < 2: # remove the item from the list for j in range(len(multiPingList)): @@ -589,6 +592,24 @@ def handleMultiPing(nodeID=0, deviceID=1): multiPingList.pop(j) break + +def handleWxBroadcast(deviceID=1): + # only allow API call every 30 minutes + clock = datetime.now() + if clock.minute % 30 != 0: + return False + + # check for alerts + alert = alertBrodcast() + if alert: + msg = f"๐Ÿšจ {alert[1]} EAS ALERTs: {alert[0]}" + if isinstance(wxAlertBroadcastChannel, list): + for channel in wxAlertBroadcastChannel: + send_message(msg, int(channel), 0, deviceID) + else: + send_message(msg, wxAlertBroadcastChannel, 0, deviceID) + return True + def onDisconnect(interface): global retry_int1, retry_int2 rxType = type(interface).__name__ @@ -931,6 +952,7 @@ async def handleSentinel(deviceID=1): # Locate Closest Nodes and report them to a secure channel # async function for possibly demanding back location data enemySpotted = "" + resolution = "unknown" closest_nodes = get_closest_nodes(deviceID) if closest_nodes != ERROR_FETCHING_DATA and closest_nodes: if closest_nodes[0]['id'] is not None: @@ -944,7 +966,9 @@ async def handleSentinel(deviceID=1): # check the positionMetadata for nodeID and get metadata if positionMetadata and closest_nodes[0]['id'] in positionMetadata: metadata = positionMetadata[closest_nodes[0]['id']] - resolution = metadata.get('precisionBits', 'na') + if metadata.get('precisionBits') is not None: + resolution = metadata.get('precisionBits') + logger.warning(f"System: {enemySpotted} is close to your location on Interface1 Accuracy is {resolution}bits") send_message(f"Sentry{deviceID}: {enemySpotted}", secure_channel, 0, deviceID) @@ -976,6 +1000,9 @@ async def watchdog(): # multiPing handler handleMultiPing(0,1) + if wxAlertBroadcastEnabled: + handleWxBroadcast(1) + # Telemetry data int1Data = displayNodeTelemetry(0, 1) if int1Data != -1 and telemetryData[0]['lastAlert1'] != int1Data: @@ -1005,6 +1032,9 @@ async def watchdog(): # multiPing handler handleMultiPing(0,2) + if wxAlertBroadcastEnabled: + handleWxBroadcast(2) + # Telemetry data int2Data = displayNodeTelemetry(0, 2) if int2Data != -1 and telemetryData[0]['lastAlert2'] != int2Data: @@ -1016,3 +1046,5 @@ async def watchdog(): await retry_interface(2) except Exception as e: logger.error(f"System: retrying interface2: {e}") + +