diff --git a/README.md b/README.md index 5abbc75..0bcd3d9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,316 @@ -# Meshbot_weather -A weather bot with alerts and forecast. Designed to run on a Raspberry pi with a connected Meshtastic radio. +# MeshBot Weather + +![Meshbot](img/meshbot_weather.png) + + +![](img/1.png) + + +![](img/2.png) + + +![](img/hourly.png) + + +![](img/5day.png) + + +![](img/temp.png) + + +![](img/4day.png) + + +![](img/alertstatus.png) + + +![](img/help.png) + + +![](img/menu.png) + + +MeshBot Weather is a spinoff of [MeshBot](https://github.com/868meshbot/meshbot) with a detailed focus on weather. Designed to run on a computer or a +Raspberry Pi with a connected Meshtastic device. + +Our Mission: + + - To provide accurate weather forecast via the mesh. + - To provide weather alerts anywhere, including locations that don't receive NOAA radio broadcast. + - To allow simple customization of different parameters to better suit your deployment. + - To be lightweight, if the computer can run Python, it can run MeshBot Weather + - To be opensource an open community, modify at will. Please publish and share your meshtastic work. + +## Features + +- Utilizes the National Weather Service, the official source for NOAA-issued EAS alerts. +- Automatically sends severe weather alerts to all devices on the mesh network. +- Weather forcast: A selection of Multi-day and hourly forcast available on demand. +- Easily accesible menu that can be called by sending "?" to the bot. +- Help message reply when the bot receives an unrecognized instruction. +- Alert system test command to varify the weather alert api is responding and is configured correctly. +- Detailed multi-message outputs for deployments on private and low-traffic meshes. +- Includes a variety of single message options for use on meshes that are high traffic / high utilization. +- Ability to enforce single message use by disabling multi-message outputs via the settings.yaml file. +- Configurable node daily reboot function. Useful if your current firmware is a little less than stable. +- Forcast are generated for any location. Not limited to towns or cities. + +## Bot interaction + +You bot will be accessible through the meshtastic mesh network through the node name. DM the bot/node and issue any of the following commands: + +- ? : receive a message with a menu of all weather commands. +- hourly : 24 hour hourly forcast with temp, rain chance, and sky conditions in emoji form. (Multi message return) +- 5day : 5 day detailed forcast (Multi message return) +- 7day : 7 day forcast in emoji form (Multi message return) +- 4day : 4 day simple forcast in emoji form (Single message return) +- 2day : Today and tomorrows detailed forcast (Single message return) +- rain : Rain chance every hour for next 24 hours (Single message return) +- temp : Predicted temperature every hour for the next 24 hours (Single message return) + + +Commands below are not listed in the help menu: +- alert-status : Runs a check on the alert system. Returns ok if good or error code if an issue is found. +- test : bot will return an acknowledgement of message received. +- tst-detail : as #test above only more detail e.g snr,rssi, hop count (thanks to [rohanki](https://github.com/rohanki)) +- advertise : When received the bot will send out a message on the public channel introducing itself along with its menu +command. + +## Requirements + +- [Python](https://www.python.org/) 3.11 or above +- Access to a [Meshtastic](https://meshtastic.org) device +- [Serial Drivers](https://meshtastic.org/docs/getting-started/serial-drivers/) for your meshtastic device +- Internet connection for the bot. + +## Meshbot Weather Installation + +1. Open your terminal, then clone this repository to your local machine: + +``` +git clone https://github.com/oasis6212/meshbot_weather.git +``` + +2. Navigate into the folder + +``` +cd meshbot_weather +``` + +3. Setup a virtual environment + +``` +python3 -m venv .venv +``` +4. Activate virtual environment + +``` +. .venv/bin/activate +``` + +5. Install the required dependencies using pip: + +``` +pip install -r requirements.txt +``` +6. Connect your Meshtastic device to your computer via USB + + +## How to run the program on various operating systems: + + + +Example on Linux: + +``` +python meshbot.py --port /dev/ttyUSB0 +``` + +Example on OSX: + +``` +python meshbot.py --port /dev/cu.usbserial-0001 +``` + +Example on Windows: + +``` +python meshbot.py --port COM7 +``` + +Example using TCP client: + +``` +python meshbot.py --host meshtastic.local +or +python meshbot.py --host 192.168.0.100 +``` +For a list of avaiable ports: +``` +python meshbot.py --help +``` +## Configuration + +The ''settings.yaml'' file; it's where you can configure different options. Can be edited in notepad. + +Example Content: + +``` +MYNODES: + - "3663493700" + - "1234567890" +DM_MODE: true +FIREWALL: false +DUTYCYCLE: false +NWS_OFFICE: "LOX" +NWS_GRID_X: "155" +NWS_GRID_Y: "45" +USER_AGENT_APP: "myweatherapp" +USER_AGENT_EMAIL: "contact@example.com" +ALERT_LAT: "34.0522" +ALERT_LON: "-118.2433" +ALERT_CHECK_INTERVAL: 300 +MESSAGE_DELAY: 15 +ALERT_INCLUDE_DESCRIPTION: false +ENABLE_7DAY_FORECAST: true +ENABLE_5DAY_FORECAST: true +ENABLE_HOURLY_WEATHER: true +FULL_MENU: true +ENABLE_AUTO_REBOOT: false +AUTO_REBOOT_HOUR: 3 +AUTO_REBOOT_MINUTE: 0 +REBOOT_DELAY_SECONDS: 10 + +``` + +Description + +- MYNODES = A list of nodes (in int/number form) that are permitted to interact with the bot + + +- DM_MODE = true: Only respond to DMs; false: responds to all traffic (recommend keeping this set to true) + + +- FIREWALL = false: if true only responds to MYNODES + + +- DUTYCYCLE: false: If true, limits itself to 10% Dutycycle + + +- NWS_OFFICE: NWS_GRID_X: NWS_GRID_Y: #settings for the weather forcast api calls, see below to learn how to set up. + + +- USER_AGENT_APP: "myweatherapp" #used for NWS (National Weather Service) API calls, can be whatever you want, more +unique the better. This is what NWS uses instead of an API key. + + +- USER_AGENT_EMAIL: "contact@example.com" #your email, in the event NWS detects excess api calls they will throttle you. +Gives you the opportunity to fix the issue and stop getting throttled. + + +- ALERT_LAT: "34.0522" ALERT_LON: "-118.2433" #settings for alerts, put in the latitude, and longitude of the area you +want alerts for. Make sure you only go up to 4 places past the decimal point on each. + + +- ALERT_CHECK_INTERVAL: # Time in seconds. How often the alert API is called. NWS does not publish allowable limits. +From what I have gathered, they allow up to once a minute for alert checking. Your milage may very. + + +- MESSAGE_DELAY: Delay in seconds between split messages. To short of a delay can cause messages to arrive out of order. + + +- ALERT_INCLUDE_DESCRIPTION: #Set to false to exclude description from alerts. Descriptions will include alot of detail +such as every county, town, and area affected. You can expect about 4 or 5 messages when description is set to "true" vs +a single message when set to false. + + +- ENABLE_7DAY_FORECAST: ENABLE_5DAY_FORECAST: ENABLE_HOURLY_WEATHER: # These calls produce 2 to 4 messages each. If you +are on a high-traffic mesh, you may want to disable these. + + +- FULL_MENU: false # When true, includes all weather commands. When false, shows only forecast options that return a +single message. + + +- ENABLE_AUTO_REBOOT: false # Some firmware versions may experience Wi-Fi instability after the node has been running +for several days. If you encounter this issue, consider enabling the auto-reboot function by setting this to "true". + + +- AUTO_REBOOT_HOUR: 3 # Hour for daily reboot (24-hour format) + + +- AUTO_REBOOT_MINUTE: 0 # Minute for daily reboot. + + +- REBOOT_DELAY_SECONDS: 10 # This delay is executed on the node itself to give it time to prepare. Recommend not +changing this. + + +## How to get your NWS_OFFICE, NWS_GRID_X, and NWS_GRID_Y +To get your NWS office and grid coordinates: +1. Go to (https://weather.gov) +2. Enter your address +3. The URL will change to something like: `https://forecast.weather.gov/MapClick.php?lat=XX.XXXX&lon=YY.YYYY` +4. Visit (https://api.weather.gov/points/XX.XXXX,YY.YYYY) (using your coordinates) + +Example: https://api.weather.gov/points/36.3741,-119.2702 +(If you know them, you can replace the coordinates in this link here with yours and skip the first part. Don't enter +more than 4 digits past the decimal point in your latitude and longitude numbers.) + +5. Look for the `gridId` (NWS_OFFICE) and `gridX` (NWS_GRID_X),`gridY` (NWS_GRID_Y) values in the response. You will +have to scroll down the page some. + +![](./img/Grid.png) + + +Enter this info into the yaml file. + +For the alert settings in the yaml file, enter your gps coordinates or use the coordinates you retrieved earlier in this +process. Use no more than four digits after the decimal point. + + + +## API Handaling details + +To prevent excessive api calls, the bot will check if it currently has the data being requested and if it is +less than an hour old. If both those conditions are met, the bot will use its catched data. If not, it will refresh the +weather info. It will not produce more than two api calls per hour for weather forcast. One for hourly data and the +other for daily data. If there are no mesh side weather requests, then no api calls are made. + +Alerts are refreshed every five minutes by default. This is configurable via the "settings.yaml" file. Due to the nature +of the data being requested, this is considered acceptable. The NWS does not post its api call limits, but will throttle +you if they deem it excessive. What I've gathered from home automation groups is you can make the alert api call up to +every minute without issue. + +You should set these options in your settings.yaml file + +USER_AGENT_APP: "myweatherapp" + +USER_AGENT_EMAIL: "contact@example.com" + +Most weather api's use a key to identify your specific instance. Instead, the NWS uses this to identify you. It's more +convenient because you don't have to actually sign up for anything, and instead just use unique info instead. + +The bot will work with the defaults here, but if you run it with these, your api calls will be added up along with +everyone else running the defaults. This could cause your API request to be throttled. + + + +## Contributors + +- [868meshbot](https://github.com/868meshbot), [oasis6212](https://github.com/oasis6212) + +## Acknowledgements + +This project utilizes the Meshtastic Python library, which provides communication capabilities for Meshtastic devices. +For more information about Meshtastic, visit [meshtastic.org](https://meshtastic.org/). + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Legal +This project is neither endorsed by nor supported by Meshtastic. + +Meshtasticยฎ is a registered trademark of Meshtastic LLC. Meshtastic software components are released under various +licenses, see GitHub for details. No warranty is provided - use at your own risk. \ No newline at end of file diff --git a/img/1.png b/img/1.png new file mode 100644 index 0000000..a1af1ee Binary files /dev/null and b/img/1.png differ diff --git a/img/2.png b/img/2.png new file mode 100644 index 0000000..7756d63 Binary files /dev/null and b/img/2.png differ diff --git a/img/2day.png b/img/2day.png new file mode 100644 index 0000000..4b20371 Binary files /dev/null and b/img/2day.png differ diff --git a/img/4day.png b/img/4day.png new file mode 100644 index 0000000..c468f5b Binary files /dev/null and b/img/4day.png differ diff --git a/img/5day.png b/img/5day.png new file mode 100644 index 0000000..e7305d2 Binary files /dev/null and b/img/5day.png differ diff --git a/img/Grid.png b/img/Grid.png new file mode 100644 index 0000000..abd7061 Binary files /dev/null and b/img/Grid.png differ diff --git a/img/alertstatus.png b/img/alertstatus.png new file mode 100644 index 0000000..f44dca9 Binary files /dev/null and b/img/alertstatus.png differ diff --git a/img/help.png b/img/help.png new file mode 100644 index 0000000..89937df Binary files /dev/null and b/img/help.png differ diff --git a/img/hourly.png b/img/hourly.png new file mode 100644 index 0000000..bd1e5a0 Binary files /dev/null and b/img/hourly.png differ diff --git a/img/menu.png b/img/menu.png new file mode 100644 index 0000000..59506e4 Binary files /dev/null and b/img/menu.png differ diff --git a/img/meshbot_weather.png b/img/meshbot_weather.png new file mode 100644 index 0000000..966acb0 Binary files /dev/null and b/img/meshbot_weather.png differ diff --git a/img/rain.png b/img/rain.png new file mode 100644 index 0000000..4761d89 Binary files /dev/null and b/img/rain.png differ diff --git a/img/temp.png b/img/temp.png new file mode 100644 index 0000000..68a3e2a Binary files /dev/null and b/img/temp.png differ diff --git a/meshbot.py b/meshbot.py new file mode 100644 index 0000000..94536ae --- /dev/null +++ b/meshbot.py @@ -0,0 +1,505 @@ +# !python3 +# -*- coding: utf-8 -*- + +""" +Weather Bot +======================= + +meshbot.py: A message bot designed for Meshtastic, providing information from modules upon request: +* weather forcast +* weather Alerts + +Author: +- Andy +- April 2024 + +MIT License + +Copyright (c) 2024 Andy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import argparse +import logging +import threading +import time +import yaml +import datetime +import random + +try: + import meshtastic.serial_interface + import meshtastic.tcp_interface + from pubsub import pub +except ImportError: + print( + "ERROR: Missing meshtastic library!\nYou can install it via pip:\npip install meshtastic\n" + ) + +import serial.tools.list_ports +import requests + +from modules.temperature_24hour import Temperature24HourFetcher +from modules.forecast_2day import Forecast2DayFetcher +from modules.hourly_weather import EmojiWeatherFetcher +from modules.rain_24hour import RainChanceFetcher +from modules.forecast_5day import NWSWeatherFetcher5Day +from modules.weather_data_manager import WeatherDataManager +from modules.weather_alert_monitor import WeatherAlerts +from modules.forecast_4day import Forecast4DayFetcher +from modules.forecast_7day import Forecast7DayFetcher + + +UNRECOGNIZED_MESSAGES = [ + "Oops! I didn't recognize that command. Type '?' to see a list of options.", + "I'm not sure what you mean. Type '?' for available commands.", + "That command isn't in my vocabulary. Send '?' to see what I understand.", + "Hmm, I don't know that one. Send '?' for a list of commands I know.", + "Sorry, I didn't catch that. Send '?' to see what commands you can use.", + "Well that's definitely not in my programming. Type '?' before we both crash.", + "Oh sure, just make up commands. Type '?' for the real ones." +] + + +def find_serial_ports(): + ports = [port.device for port in serial.tools.list_ports.comports()] + filtered_ports = [ + port for port in ports if "COM" in port.upper() or "USB" in port.upper() + ] + return filtered_ports + + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger() + +# GLOBALS +MYNODE = "" +MYNODES = "" +DM_MODE = "" +FIREWALL = "" +DUTYCYCLE = "" + +with open("settings.yaml", "r") as file: + settings = yaml.safe_load(file) + +MYNODES = settings.get("MYNODES") +DM_MODE = settings.get("DM_MODE") +FIREWALL = settings.get("FIREWALL") +DUTYCYCLE = settings.get("DUTYCYCLE") + +NWS_OFFICE = settings.get("NWS_OFFICE", "HNX") +NWS_GRID_X = settings.get("NWS_GRID_X", "67") +NWS_GRID_Y = settings.get("NWS_GRID_Y", "80") + +USER_AGENT_APP = settings.get("USER_AGENT_APP", "myweatherapp") +USER_AGENT_EMAIL = settings.get("USER_AGENT_EMAIL", "contact@example.com") +USER_AGENT = f"({USER_AGENT_APP}, {USER_AGENT_EMAIL})" + +logger.info(f"DUTYCYCLE: {DUTYCYCLE}") +logger.info(f"DM_MODE: {DM_MODE}") +logger.info(f"FIREWALL: {FIREWALL}") + +transmission_count = 0 +cooldown = False + +temperature_24hour_info = None +forecast_2day_info = None +emoji_weather_info = None +rain_chance_info = None + +weather_manager = WeatherDataManager( + NWS_OFFICE, + NWS_GRID_X, + NWS_GRID_Y, + USER_AGENT +) + +# Initialize weather classes with weather manager +temperature_24hour = Temperature24HourFetcher(weather_manager) +forecast_2day = Forecast2DayFetcher(weather_manager) +emoji_weather_fetcher = EmojiWeatherFetcher(weather_manager) +rain_chance_fetcher = RainChanceFetcher(weather_manager) +nws_weather_fetcher_5day = NWSWeatherFetcher5Day(weather_manager) +forecast_4day = Forecast4DayFetcher(weather_manager) +forecast_7day = Forecast7DayFetcher(weather_manager) + + +def get_temperature_24hour(): + global temperature_24hour_info + temperature_24hour_info = temperature_24hour.get_temperature_24hour() + return temperature_24hour_info + + +def get_forecast_2day(): + global forecast_2day_info + forecast_2day_info = forecast_2day.get_daily_weather() + return forecast_2day_info + + +def get_emoji_weather(): + global emoji_weather_info + emoji_weather_info = emoji_weather_fetcher.get_emoji_weather() + return emoji_weather_info + + +def get_rain_chance(): + global rain_chance_info + rain_chance_info = rain_chance_fetcher.get_rain_chance() + return rain_chance_info + + +def reset_transmission_count(): + global transmission_count + if settings.get('DUTYCYCLE', False): + transmission_count -= 1 + if transmission_count < 0: + transmission_count = 0 + logger.info(f"Reducing transmission count {transmission_count}") + threading.Timer(180.0, reset_transmission_count).start() + + +def reset_cooldown(): + global cooldown + cooldown = False + logger.info("Cooldown Disabled.") + threading.Timer(240.0, reset_cooldown).start() + + +def split_message(message, max_length=175, message_type="Hourly"): + lines = message.split('\n') + messages = [] + current_message = [] + current_length = 0 + + for line in lines: + line_length = len(line.encode('utf-8')) + (1 if current_message else 0) + + if current_length + line_length > max_length: + messages.append('\n'.join(current_message)) + current_message = [] + current_length = 0 + + current_message.append(line) + current_length += line_length + + if current_message: + messages.append('\n'.join(current_message)) + + for i in range(len(messages)): + messages[i] = f"--({i + 1}/{len(messages)}) {message_type}\n" + messages[i] + + return messages + + +def get_forecast_4day(): + global forecast_4day_info + forecast_4day_info = forecast_4day.get_weekly_emoji_weather() + return "\n".join(forecast_4day_info) + + +def message_listener(packet, interface): + global transmission_count + global cooldown + global DM_MODE + global FIREWALL + global DUTYCYCLE + global MYNODE + + if packet is not None and packet["decoded"].get("portnum") == "TEXT_MESSAGE_APP": + message = packet["decoded"]["text"].lower() + sender_id = packet["from"] + + # Check if it's a DM + is_direct_message = False + if "to" in packet: + is_direct_message = str(packet["to"]) == str(MYNODE) + + # Only log if it's a DM + if is_direct_message: + logger.info(f"Message {packet['decoded']['text']} from {packet['from']}") + logger.info(f"transmission count {transmission_count}") + + # Enforce DM_MODE + if DM_MODE and not is_direct_message: + return + + # firewall logging + if FIREWALL and not any(node in str(packet["from"]) for node in MYNODES): + logger.warning(f"Firewall blocked message from {packet['from']}: {message}") + return + + if (transmission_count < 16 or DUTYCYCLE == False): + if "test" in message: + transmission_count += 1 + interface.sendText("๐ŸŸข ACK", wantAck=True, destinationId=sender_id) + elif "?" in message: + transmission_count += 1 + if settings.get('FULL_MENU', True): # Default to full menu if setting not found + interface.sendText( + " --Multi-Message--\n" + "hourly - 24h outlook\n" + "7day - 7 day simple\n" + "5day - 5 day detailed\n\n" + " --Single Message--\n" + "2day - 2 day detailed\n" + "4day - 4 day simple\n" + "rain - 24h precipitation\n" + "temp - 24h temperature\n" + , wantAck=True, destinationId=sender_id) + else: + interface.sendText( + " --Weather Commands--\n" + "2day - 2 day forecast\n" + "4day - 4 day forecast\n" + "temp - 24h temperature\n" + "rain - 24h precipitation" + , wantAck=True, destinationId=sender_id) + elif "temp" in message: + transmission_count += 1 + interface.sendText(get_temperature_24hour(), wantAck=True, destinationId=sender_id) + elif "2day" in message: + transmission_count += 1 + interface.sendText(get_forecast_2day(), wantAck=True, destinationId=sender_id) + elif "hourly" in message: + if settings.get('ENABLE_HOURLY_WEATHER', True): + transmission_count += 1 + weather_data = get_emoji_weather() + messages = split_message(weather_data) + for i, msg in enumerate(messages): + interface.sendText(msg, wantAck=True, destinationId=sender_id) + if i < len(messages) - 1: + time.sleep(settings.get('MESSAGE_DELAY', 10)) + else: + interface.sendText("Hourly weather module is disabled.", wantAck=True, destinationId=sender_id) + elif "rain" in message: + transmission_count += 1 + interface.sendText(get_rain_chance(), wantAck=True, destinationId=sender_id) + elif "5day" in message: + if settings.get('ENABLE_5DAY_FORECAST', True): + transmission_count += 1 + weather_messages = nws_weather_fetcher_5day.get_daily_weather() + for i, msg in enumerate(weather_messages): + interface.sendText(msg, wantAck=True, destinationId=sender_id) + if i < len(weather_messages) - 1: + time.sleep(settings.get('MESSAGE_DELAY', 10)) + else: + interface.sendText("5-day forecast module is disabled.", wantAck=True, destinationId=sender_id) + elif "4day" in message: + transmission_count += 1 + interface.sendText(get_forecast_4day(), wantAck=True, destinationId=sender_id) + elif "advertise" in message: + transmission_count += 1 + interface.sendText( + "Hello, I am a weather bot, DM me \"#?\" for a list of forcast options.", + wantAck=True, + destinationId="^all" + ) + elif "7day" in message: # Changed from 10day + if settings.get('ENABLE_7DAY_FORECAST', True): # Changed from ENABLE_10DAY_FORECAST + transmission_count += 1 + weather_data = forecast_7day.get_weekly_emoji_weather() + messages = split_message(weather_data, message_type="7day") # Changed from 10day + for i, msg in enumerate(messages): + interface.sendText(msg, wantAck=True, destinationId=sender_id) + if i < len(messages) - 1: + time.sleep(settings.get('MESSAGE_DELAY', 10)) + else: + interface.sendText("7-day forecast module is disabled.", wantAck=True, destinationId=sender_id) + elif "alert-status" in message: + transmission_count += 1 + interface.sendText(get_weather_alert_status(), wantAck=True, destinationId=sender_id) + else: + # If it's a DM but doesn't match any command, send a random help message + if is_direct_message: + transmission_count += 1 + interface.sendText( + random.choice(UNRECOGNIZED_MESSAGES), + wantAck=True, + destinationId=sender_id + ) + + if transmission_count >= 11 and DUTYCYCLE == True: + if not cooldown: + interface.sendText( + "โŒ Bot has reached duty cycle, entering cool down... โ„", + wantAck=False, + ) + logger.info("Cooldown enabled.") + cooldown = True + logger.info( + "Duty cycle limit reached. Please wait before transmitting again." + ) + + +def main(): + logger.info("Starting program.") + reset_transmission_count() + if settings.get('DUTYCYCLE', False): + reset_cooldown() + + parser = argparse.ArgumentParser(description="Meshbot_Weather a bot for Meshtastic devices") + parser.add_argument("--port", type=str, help="Specify the serial port to probe") + parser.add_argument("--db", type=str, help="Specify DB: mpowered or liam") + parser.add_argument("--host", type=str, help="Specify meshtastic host (IP address) if using API") + + args = parser.parse_args() + + if args.port: + serial_ports = [args.port] + logger.info(f"Serial port {serial_ports}\n") + elif args.host: + ip_host = args.host + print(ip_host) + logger.info(f"Meshtastic API host {ip_host}\n") + else: + serial_ports = find_serial_ports() + if serial_ports: + logger.info("Available serial ports:") + for port in serial_ports: + logger.info(port) + logger.info( + "Im not smart enough to work out the correct port, please use the --port argument with a relevent meshtastic port" + ) + else: + logger.info("No serial ports found.") + exit(0) + + logger.info(f"Press CTRL-C x2 to terminate the program") + + # Create interface + if args.host: + interface = meshtastic.tcp_interface.TCPInterface(hostname=ip_host, noProto=False) + else: + interface = meshtastic.serial_interface.SerialInterface(serial_ports[0]) + + global MYNODE + MYNODE = get_my_node_id(interface) + logger.info(f"Automatically detected MYNODE ID: {MYNODE}") + + if DM_MODE and not MYNODE: + logger.error("DM_MODE is enabled but failed to get MYNODE ID. Please check connection to device.") + exit(1) + + if settings.get('ENABLE_AUTO_REBOOT', True): + reboot_thread = threading.Thread( + target=schedule_daily_reboot, + args=(interface,), + daemon=True + ) + reboot_thread.start() + logger.info("Daily reboot scheduler started") + + try: + my_info = interface.getMyNodeInfo() + logger.info("Connected to Meshtastic Node:") + logger.info(f"Node Name: {my_info.get('user', {}).get('longName', 'Unknown')}") + except Exception as e: + logger.error(f"Failed to get node info: {e}") + + message_delay = settings.get('MESSAGE_DELAY', 10) + + alerts = WeatherAlerts( + settings.get("ALERT_LAT"), + settings.get("ALERT_LON"), + interface, + settings.get("USER_AGENT_APP"), + settings.get("USER_AGENT_EMAIL"), + settings.get("ALERT_CHECK_INTERVAL", 300), + message_delay=message_delay, + settings=settings + ) + alerts.start_monitoring() + pub.subscribe(message_listener, "meshtastic.receive") + + while True: + time.sleep(1) + + +def schedule_daily_reboot(interface): + if not settings.get('ENABLE_AUTO_REBOOT', True): + return + + reboot_hour = settings.get('AUTO_REBOOT_HOUR', 3) + reboot_minute = settings.get('AUTO_REBOOT_MINUTE', 0) + reboot_delay = settings.get('REBOOT_DELAY_SECONDS', 10) + + while True: + now = datetime.datetime.now() + next_reboot = now.replace( + hour=reboot_hour, + minute=reboot_minute, + second=0, + microsecond=0 + ) + + if now >= next_reboot: + next_reboot += datetime.timedelta(days=1) + + seconds_until_reboot = (next_reboot - now).total_seconds() + time.sleep(seconds_until_reboot) + + try: + logger.info(f"Executing scheduled reboot at {next_reboot}") + interface.localNode.reboot(secs=reboot_delay) + except Exception as e: + logger.error(f"Failed to execute scheduled reboot: {e}") + + +def get_my_node_id(interface): + try: + my_info = interface.getMyNodeInfo() + return str(my_info.get('num', '')) + except Exception as e: + logger.error(f"Failed to get node info: {e}") + return '' + +def get_weather_alert_status(): + """ + Check if the weather alert monitor is functioning properly. + Returns a status message indicating if the system is working or not. + """ + try: + # Build the API URL and parameters similar to weather_alert_monitor.py + base_url = f"https://api.weather.gov/alerts/active" + params = { + "point": f"{settings.get('ALERT_LAT')},{settings.get('ALERT_LON')}" + } + headers = { + "User-Agent": f"({settings.get('USER_AGENT_APP')}, {settings.get('USER_AGENT_EMAIL')})" + } + + # Test the API connection + response = requests.get(base_url, params=params, headers=headers) + response.raise_for_status() + + # If we get here, the connection is working + return "๐ŸŸข Alert System: Active and monitoring for weather alerts" + + except requests.exceptions.RequestException as e: + logger.error(f"Weather Alert Monitor Status Check Failed: {str(e)}") + return "๐Ÿ”ด Alert System: Unable to connect to weather service" + except Exception as e: + logger.error(f"Weather Alert Monitor Status Check Failed: {str(e)}") + return "๐Ÿ”ด Alert System: Service interrupted - check logs" + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/__pycache__/__init__.cpython-311.pyc b/modules/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..3a5b59c Binary files /dev/null and b/modules/__pycache__/__init__.cpython-311.pyc differ diff --git a/modules/__pycache__/forecast_2day.cpython-311.pyc b/modules/__pycache__/forecast_2day.cpython-311.pyc new file mode 100644 index 0000000..7f2fc7d Binary files /dev/null and b/modules/__pycache__/forecast_2day.cpython-311.pyc differ diff --git a/modules/__pycache__/forecast_4day.cpython-311.pyc b/modules/__pycache__/forecast_4day.cpython-311.pyc new file mode 100644 index 0000000..e03e56d Binary files /dev/null and b/modules/__pycache__/forecast_4day.cpython-311.pyc differ diff --git a/modules/__pycache__/forecast_5day.cpython-311.pyc b/modules/__pycache__/forecast_5day.cpython-311.pyc new file mode 100644 index 0000000..121d1e8 Binary files /dev/null and b/modules/__pycache__/forecast_5day.cpython-311.pyc differ diff --git a/modules/__pycache__/forecast_7day.cpython-311.pyc b/modules/__pycache__/forecast_7day.cpython-311.pyc new file mode 100644 index 0000000..b2bd55f Binary files /dev/null and b/modules/__pycache__/forecast_7day.cpython-311.pyc differ diff --git a/modules/__pycache__/hourly_weather.cpython-311.pyc b/modules/__pycache__/hourly_weather.cpython-311.pyc new file mode 100644 index 0000000..04e2f65 Binary files /dev/null and b/modules/__pycache__/hourly_weather.cpython-311.pyc differ diff --git a/modules/__pycache__/rain_24hour.cpython-311.pyc b/modules/__pycache__/rain_24hour.cpython-311.pyc new file mode 100644 index 0000000..5a7d281 Binary files /dev/null and b/modules/__pycache__/rain_24hour.cpython-311.pyc differ diff --git a/modules/__pycache__/temperature_24hour.cpython-311.pyc b/modules/__pycache__/temperature_24hour.cpython-311.pyc new file mode 100644 index 0000000..1158d1b Binary files /dev/null and b/modules/__pycache__/temperature_24hour.cpython-311.pyc differ diff --git a/modules/__pycache__/weather_alert_monitor.cpython-311.pyc b/modules/__pycache__/weather_alert_monitor.cpython-311.pyc new file mode 100644 index 0000000..899afd0 Binary files /dev/null and b/modules/__pycache__/weather_alert_monitor.cpython-311.pyc differ diff --git a/modules/__pycache__/weather_data_manager.cpython-311.pyc b/modules/__pycache__/weather_data_manager.cpython-311.pyc new file mode 100644 index 0000000..63eae38 Binary files /dev/null and b/modules/__pycache__/weather_data_manager.cpython-311.pyc differ diff --git a/modules/forecast_2day.py b/modules/forecast_2day.py new file mode 100644 index 0000000..f6ed475 --- /dev/null +++ b/modules/forecast_2day.py @@ -0,0 +1,51 @@ +import logging + +class Forecast2DayFetcher: + def __init__(self, weather_manager): + self.weather_manager = weather_manager + + def get_daily_weather(self): + try: + data = self.weather_manager.get_daily_data() + if not data: + return "Error: Unable to fetch weather data" + + periods = data['properties']['periods'][:5] # Get first 5 periods + + result = [] + for i, period in enumerate(periods): + name = period['name'] + temp = period['temperature'] + forecast = period['shortForecast'] + + if 'night' in name.lower(): + if 'tonight' in name.lower(): + output = f"Tonight's Low {temp}. {forecast}" + else: + day = name.replace(' Night', '') + output = f"{day} Night Low {temp}. {forecast}" + else: + if name == "Today": + output = f"Today's High {temp}. {forecast}" + else: + output = f"{name} High {temp}. {forecast}" + + # Add double newline before all periods except the first one + if i > 0: + output = "\n\n" + output + + result.append(output) + + # Join and ensure total length is under 200 + full_text = "".join(result) + if len(full_text) > 200: + # If too long, reduce to 3 periods + result = result[:3] + full_text = "".join(result) + + return full_text + + except Exception as e: + error_msg = f"Error fetching weather data: {str(e)}" + logging.error(error_msg) + return error_msg \ No newline at end of file diff --git a/modules/forecast_4day.py b/modules/forecast_4day.py new file mode 100644 index 0000000..c0fe9c7 --- /dev/null +++ b/modules/forecast_4day.py @@ -0,0 +1,125 @@ +import logging +from datetime import datetime + +class Forecast4DayFetcher: + def __init__(self, weather_manager): + self.weather_manager = weather_manager + self.weather_emojis = { + "clear": "๐ŸŒ™", + "sunny": "โ˜€๏ธ", + "partly sunny": "๐ŸŒค๏ธ", + "mostly sunny": "๐ŸŒค๏ธ", + "partly cloudy": "โ›…", + "mostly cloudy": "๐ŸŒฅ๏ธ", + "cloudy": "โ˜๏ธ", + "rain": "๐ŸŒง๏ธ", + "showers": "๐ŸŒง๏ธ", + "thunderstorm": "โ›ˆ๏ธ", + "snow": "๐ŸŒจ๏ธ", + "fog": "๐ŸŒซ๏ธ" + } + self.rain_emoji = "๐Ÿ’ง" + + def _get_emoji(self, forecast): + forecast = forecast.lower() + if "thunderstorm" in forecast: + return self.weather_emojis["thunderstorm"] + for key, emoji in self.weather_emojis.items(): + if key in forecast: + return emoji + return "๐ŸŒก๏ธ" + + def _get_rain_chance(self, properties): + prob = properties.get('probabilityOfPrecipitation', {}).get('value', 0) + return 0 if prob is None else prob + + def _format_day_name(self, name, is_first_period=False): + # Handle special cases for the first period + if is_first_period: + if 'night' in name.lower(): + return "Tonight" + return "Today" + + # For other periods, use first 3 letters of the day name + name = name.split()[0] + return name[:3] + + def get_weekly_emoji_weather(self): + try: + data = self.weather_manager.get_daily_data() + if not data: + return ["Error: Unable to fetch weather data"] + + if 'properties' not in data or 'periods' not in data['properties']: + return ["Error: Invalid weather data format"] + + periods = data['properties']['periods'] + result = [] + + # Check if first period is night + starts_with_night = 'night' in periods[0]['name'].lower() + + # If starting with night, we need 9 periods to get 4 full days + needed_periods = 9 if starts_with_night else 8 + periods = periods[:needed_periods] + + if starts_with_night: + # First line will only have night data + night_period = periods[0] + day_name = self._format_day_name(night_period['name'], True) + low_temp = night_period['temperature'] + night_emoji = self._get_emoji(night_period['shortForecast']) + night_rain = self._get_rain_chance(night_period) + + line = f"{day_name} {self.rain_emoji}{night_rain}% โŒ {night_emoji} โŒ โ†“{low_temp}ยฐ" + result.append(line) + + # Process remaining days + for i in range(1, len(periods) - 1, 2): + day_period = periods[i] + night_period = periods[i + 1] + + day_name = self._format_day_name(day_period['name']) + + day_rain = self._get_rain_chance(day_period) + night_rain = self._get_rain_chance(night_period) + max_rain = max(day_rain, night_rain) + + day_emoji = self._get_emoji(day_period['shortForecast']) + night_emoji = self._get_emoji(night_period['shortForecast']) + + high_temp = day_period['temperature'] + low_temp = night_period['temperature'] + + line = f"{day_name} {self.rain_emoji}{max_rain}% {day_emoji} {night_emoji} โ†‘{high_temp}ยฐ โ†“{low_temp}ยฐ" + result.append(line) + else: + # Process all days normally, with special handling for first day + for i in range(0, min(len(periods), 8), 2): + day_period = periods[i] + night_period = periods[i + 1] if i + 1 < len(periods) else None + + if not night_period: + continue + + day_name = self._format_day_name(day_period['name'], i == 0) + + day_rain = self._get_rain_chance(day_period) + night_rain = self._get_rain_chance(night_period) + max_rain = max(day_rain, night_rain) + + day_emoji = self._get_emoji(day_period['shortForecast']) + night_emoji = self._get_emoji(night_period['shortForecast']) + + high_temp = day_period['temperature'] + low_temp = night_period['temperature'] + + line = f"{day_name} {self.rain_emoji}{max_rain}% {day_emoji} {night_emoji} โ†‘{high_temp}ยฐ โ†“{low_temp}ยฐ" + result.append(line) + + return result[:4] # Only return first 4 days + + except Exception as e: + error_msg = f"Error fetching weather data: {str(e)}" + logging.error(error_msg) + return [error_msg] \ No newline at end of file diff --git a/modules/forecast_5day.py b/modules/forecast_5day.py new file mode 100644 index 0000000..aebaee2 --- /dev/null +++ b/modules/forecast_5day.py @@ -0,0 +1,65 @@ + +import logging + + +class NWSWeatherFetcher5Day: + def __init__(self, weather_manager): + self.weather_manager = weather_manager + + def get_daily_weather(self): + try: + data = self.weather_manager.get_daily_data() + if not data: + return ["Error: Unable to fetch weather data"] + + periods = data['properties']['periods'][:10] # Get 10 periods (5 days) + + result = [] + for period in periods: + name = period['name'] + temp = period['temperature'] + forecast = period['shortForecast'] + + if 'night' in name.lower(): + if 'tonight' in name.lower(): + output = f"Tonight's Low {temp}. {forecast}" + else: + day = name.replace(' Night', '') + output = f"{day} Night Low {temp}. {forecast}" + else: + if name == "Today": + output = f"Today's High {temp}. {forecast}" + else: + output = f"{name} High {temp}. {forecast}" + + result.append(output) + + # Format with double newlines before splitting + formatted_text = "\n\n".join(result) + + # Split into chunks that fit within character limit + chunks = [] + while formatted_text: + if len(formatted_text) <= 175: + chunks.append(formatted_text) + break + else: + # Find last double newline before 175 chars + split_point = formatted_text[:175].rfind('\n\n') + if split_point == -1: + split_point = 175 + + chunks.append(formatted_text[:split_point]) + formatted_text = formatted_text[split_point:].lstrip() + + # Add message numbering + messages = [] + for i, chunk in enumerate(chunks): + messages.append(f"--({i + 1}/{len(chunks)}) 5-Day\n\n{chunk}") + + return messages + + except Exception as e: + error_msg = f"Error fetching weather data: {str(e)}" + logging.error(error_msg) + return [error_msg] \ No newline at end of file diff --git a/modules/forecast_7day.py b/modules/forecast_7day.py new file mode 100644 index 0000000..3fdcb21 --- /dev/null +++ b/modules/forecast_7day.py @@ -0,0 +1,131 @@ +class Forecast7DayFetcher: # Changed from Forecast10DayFetcher + def __init__(self, weather_manager): + self.weather_manager = weather_manager + self.weather_emojis = { + "clear": "๐ŸŒ™", + "sunny": "โ˜€๏ธ", + "partly sunny": "๐ŸŒค๏ธ", + "mostly sunny": "๐ŸŒค๏ธ", + "partly cloudy": "โ›…", + "mostly cloudy": "๐ŸŒฅ๏ธ", + "cloudy": "โ˜๏ธ", + "rain": "๐ŸŒง๏ธ", + "showers": "๐ŸŒง๏ธ", + "thunderstorm": "โ›ˆ๏ธ", + "snow": "๐ŸŒจ๏ธ", + "fog": "๐ŸŒซ๏ธ" + } + self.rain_emoji = "๐Ÿ’ง" + + def _get_emoji(self, forecast): + forecast = forecast.lower() + if "thunderstorm" in forecast: + return self.weather_emojis["thunderstorm"] + for key, emoji in self.weather_emojis.items(): + if key in forecast: + return emoji + return "๐ŸŒก๏ธ" + + def _get_rain_chance(self, properties): + prob = properties.get('probabilityOfPrecipitation', {}).get('value', 0) + return 0 if prob is None else prob + + def _format_day_name(self, name, is_first_period=False): + # Handle special cases for the first period + if is_first_period: + if 'night' in name.lower(): + return "Tonight" + return "Today" + + # For other periods, use first 3 letters of the day name + name = name.split()[0] + return name[:3] + + def get_weekly_emoji_weather(self): + try: + data = self.weather_manager.get_daily_data() + if not data: + return "Error: Unable to fetch weather data" + + if 'properties' not in data or 'periods' not in data['properties']: + return "Error: Invalid weather data format" + + periods = data['properties']['periods'] + result = [] + + # Check if first period is night + starts_with_night = 'night' in periods[0]['name'].lower() + + # If starting with night, we need 15 periods to get 7 full days + needed_periods = 15 if starts_with_night else 14 + periods = periods[:needed_periods] + + if starts_with_night: + # First line will only have night data + night_period = periods[0] + day_name = self._format_day_name(night_period['name'], True) + low_temp = night_period['temperature'] + night_emoji = self._get_emoji(night_period['shortForecast']) + night_rain = self._get_rain_chance(night_period) + + line = f"{day_name} {self.rain_emoji}{night_rain}% โŒ {night_emoji} โŒ โ†“{low_temp}ยฐ" + result.append(line) + + # Process remaining days + for i in range(1, len(periods) - 1, 2): + if len(result) >= 10: # Stop after 10 days + break + + day_period = periods[i] + night_period = periods[i + 1] + + day_name = self._format_day_name(day_period['name']) + + day_rain = self._get_rain_chance(day_period) + night_rain = self._get_rain_chance(night_period) + max_rain = max(day_rain, night_rain) + + day_emoji = self._get_emoji(day_period['shortForecast']) + night_emoji = self._get_emoji(night_period['shortForecast']) + + high_temp = day_period['temperature'] + low_temp = night_period['temperature'] + + line = f"{day_name} {self.rain_emoji}{max_rain}% {day_emoji} {night_emoji} โ†‘{high_temp}ยฐ โ†“{low_temp}ยฐ" + result.append(line) + else: + # Process all days normally, with special handling for first day + for i in range(0, min(len(periods), 20), 2): + if len(result) >= 10: # Stop after 10 days + break + + day_period = periods[i] + night_period = periods[i + 1] if i + 1 < len(periods) else None + + if not night_period: + continue + + day_name = self._format_day_name(day_period['name'], i == 0) + + day_rain = self._get_rain_chance(day_period) + night_rain = self._get_rain_chance(night_period) + max_rain = max(day_rain, night_rain) + + day_emoji = self._get_emoji(day_period['shortForecast']) + night_emoji = self._get_emoji(night_period['shortForecast']) + + high_temp = day_period['temperature'] + low_temp = night_period['temperature'] + + line = f"{day_name} {self.rain_emoji}{max_rain}% {day_emoji} {night_emoji} โ†‘{high_temp}ยฐ โ†“{low_temp}ยฐ" + result.append(line) + + # Change the return to limit to 7 days + return "\n".join(result[:7]) + + except Exception as e: + error_msg = f"Error fetching weather data: {str(e)}" + logging.error(error_msg) + return error_msg +import logging +from datetime import datetime \ No newline at end of file diff --git a/modules/hourly_weather.py b/modules/hourly_weather.py new file mode 100644 index 0000000..07d316f --- /dev/null +++ b/modules/hourly_weather.py @@ -0,0 +1,93 @@ +import logging +from datetime import datetime + + +class EmojiWeatherFetcher: + def __init__(self, weather_manager): + self.weather_manager = weather_manager + self.weather_emojis = { + "clear": "๐ŸŒ™", # Changed from โ˜€๏ธ to ๐ŸŒ™ + "sunny": "โ˜€๏ธ", + "partly sunny": "๐ŸŒค๏ธ", + "mostly sunny": "๐ŸŒค๏ธ", + "partly cloudy": "โ›…", + "mostly cloudy": "๐ŸŒฅ๏ธ", + "cloudy": "โ˜๏ธ", + "rain": "๐ŸŒง๏ธ", + "showers": "๐ŸŒง๏ธ", + "thunderstorm": "โ›ˆ๏ธ", + "snow": "๐ŸŒจ๏ธ", + "fog": "๐ŸŒซ๏ธ" + } + self.rain_emoji = "๐Ÿ’ง" + + def _get_emoji(self, forecast): + forecast = forecast.lower() + # First check for thunderstorm specifically + if "thunderstorm" in forecast: + return self.weather_emojis["thunderstorm"] + # Then check for other conditions + for key, emoji in self.weather_emojis.items(): + if key in forecast: + return emoji + return "๐ŸŒก๏ธ" # Default emoji if no match found + + def _get_rain_chance(self, properties): + prob = properties.get('probabilityOfPrecipitation', {}).get('value', 0) + return 0 if prob is None else prob + + def _format_time(self, dt): + # Get hour without leading zero + hour = dt.strftime("%I").lstrip('0') + # Get period (AM/PM) + period_str = dt.strftime("%p").lower() + + # Format time based on hour length + if len(hour) == 1: + # Single digit hour - keep the 'm' + return f"{hour}{period_str}" + else: + # Double digit hour - use 'a' or 'p' with a space + return f"{hour}{period_str[0]} " + + def get_emoji_weather(self): + try: + data = self.weather_manager.get_hourly_data() + if not data: + return "Error: Unable to fetch weather data" + + if 'properties' not in data or 'periods' not in data['properties']: + return "Error: Invalid weather data format" + + result = [] + count = 0 + # Create timezone-aware current time in UTC + current_time = datetime.now().astimezone() + + for period in data['properties']['periods']: + time_str = period['startTime'] + # Parse the API time (which includes timezone info) + dt = datetime.fromisoformat(time_str.replace('Z', '+00:00')) + + # Skip periods that are in the past + if dt <= current_time: + continue + + if count >= 23: # Limit to 23 entries + break + + time_format = self._format_time(dt) + emoji = self._get_emoji(period['shortForecast']) + temp = str(round(period['temperature'])) + rain_chance = self._get_rain_chance(period) + + line = f"{time_format}{emoji}{temp}ยฐ{self.rain_emoji}{rain_chance}%" + result.append(line) + count += 1 + + return "\n".join(result) + + except Exception as e: + error_msg = f"Error: {str(e)}" + logging.error(error_msg) + return error_msg \ No newline at end of file diff --git a/modules/rain_24hour.py b/modules/rain_24hour.py new file mode 100644 index 0000000..aa50e4f --- /dev/null +++ b/modules/rain_24hour.py @@ -0,0 +1,50 @@ +from datetime import datetime + + +class RainChanceFetcher: + def __init__(self, weather_manager): + self.weather_manager = weather_manager + + def get_rain_chance(self): + try: + data = self.weather_manager.get_hourly_data() + if not data: + return "Error: Unable to fetch weather data" + + periods = data['properties']['periods'] + result = [] + count = 0 + + for period in periods: + try: + if count >= 24: + break + + time_str = period['startTime'] + dt = datetime.fromisoformat(time_str.replace('Z', '+00:00')) + + hour = dt.strftime("%I").lstrip('0') + period_str = dt.strftime("%p").lower() + + if len(hour) == 1: + time_str = f"{hour}{period_str}" + else: + time_str = f"{hour}{period_str[0]}" + + prob = period.get('probabilityOfPrecipitation', {}).get('value', 0) + rain_chance = 0 if prob is None else prob + + formatted_entry = f"{time_str}:{rain_chance}%" + result.append(formatted_entry) + count += 1 + + except Exception as e: + continue + + if not result: + return "Error: Could not process weather data" + + return "\n".join(result) + + except Exception as e: + return f"Unexpected error: {str(e)}" \ No newline at end of file diff --git a/modules/temperature_24hour.py b/modules/temperature_24hour.py new file mode 100644 index 0000000..c03aeeb --- /dev/null +++ b/modules/temperature_24hour.py @@ -0,0 +1,51 @@ +from datetime import datetime +import requests + +class Temperature24HourFetcher: + def __init__(self, weather_manager): + self.weather_manager = weather_manager + + def get_temperature_24hour(self): + try: + data = self.weather_manager.get_hourly_data() + if not data: + return "Error: Unable to fetch weather data" + + periods = data['properties']['periods'] + result = [] + count = 0 + + for period in periods: + try: + if count >= 24: + break + + time_str = period['startTime'] + dt = datetime.fromisoformat(time_str.replace('Z', '+00:00')) + + hour = dt.strftime("%I").lstrip('0') + period_str = dt.strftime("%p").lower() + + if len(hour) == 1: + time_str = f"{hour}{period_str}" + else: + time_str = f"{hour}{period_str[0]}" + + temp = round(float(period['temperature'])) + formatted_entry = f"{time_str}:{temp}ยฐ" + + result.append(formatted_entry) + count += 1 + + except Exception as e: + continue + + if not result: + return "Error: Could not process weather data" + + return "\n".join(result) + + except requests.exceptions.RequestException as e: + return f"Error fetching weather data: {str(e)}" + except Exception as e: + return f"Unexpected error: {str(e)}" \ No newline at end of file diff --git a/modules/weather_alert_monitor.py b/modules/weather_alert_monitor.py new file mode 100644 index 0000000..1c1c549 --- /dev/null +++ b/modules/weather_alert_monitor.py @@ -0,0 +1,155 @@ +import time +import threading +import requests +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) + + +class WeatherAlerts: + def __init__(self, lat, lon, interface, user_agent_app, user_agent_email, check_interval=300, sender_id=None, message_delay=10, settings=None): + """Initialize the WeatherAlerts class. + + Args: + lat (str): Latitude for the alert location + lon (str): Longitude for the alert location + interface: Interface object for sending messages + user_agent_app (str): App name for the user agent + user_agent_email (str): Contact email for the user agent + check_interval (int, optional): Time between alert checks in seconds. Defaults to 300. + sender_id (str, optional): Sender ID for messages. Defaults to None. + message_delay (int, optional): Delay between split messages in seconds. Defaults to 10. + settings (dict, optional): Additional settings dictionary. Defaults to None. + """ + self.lat = lat + self.lon = lon + self.interface = interface + self.sender_id = sender_id + self.check_interval = check_interval + self.message_delay = message_delay + self.settings = settings or {} + self.last_alert_id = None + self.base_url = "https://api.weather.gov/alerts/active" + self.params = { + "point": f"{self.lat},{self.lon}" + } + self.headers = { + "User-Agent": f"({user_agent_app}, {user_agent_email})" + } + + def split_message(self, text, max_length=175): + """Split a message into chunks of specified maximum length, preserving whole words. + + Args: + text (str): Text to split + max_length (int, optional): Maximum length of each chunk. Defaults to 175. + + Returns: + list: List of message chunks + """ + messages = [] + lines = text.split('\n') + current_chunk = [] + current_length = 0 + + for line in lines: + words = line.split() + for word in words: + # Check if adding this word would exceed the limit + # +1 for space, another +1 for potential newline + word_length = len(word) + (1 if current_chunk else 0) + + if current_length + word_length > max_length: + # Current chunk is full, save it and start a new one + if current_chunk: + messages.append(' '.join(current_chunk)) + current_chunk = [word] + current_length = len(word) + else: + current_chunk.append(word) + current_length += word_length + + # Add a newline after each original line if we're continuing the same chunk + if current_chunk: + current_length += 1 # Account for the newline + if current_length > max_length: + # If adding newline would exceed limit, start new chunk + messages.append(' '.join(current_chunk)) + current_chunk = [] + current_length = 0 + + # Don't forget the last chunk + if current_chunk: + messages.append(' '.join(current_chunk)) + + return messages + + def check_alerts(self): + """Check for new weather alerts and send notifications if needed.""" + try: + logger.info("Updated weather alerts") + response = requests.get(self.base_url, params=self.params, headers=self.headers) + response.raise_for_status() # Raise exception for bad status codes + + data = response.json() + if not data.get('features'): + return + + latest_alert = data['features'][0] + alert_id = latest_alert['properties']['id'] + + if alert_id == self.last_alert_id: + return + + self.last_alert_id = alert_id + alert_props = latest_alert['properties'] + + # Check if description should be included + include_description = self.settings.get('ALERT_INCLUDE_DESCRIPTION', True) + + # Prepare the alert message based on settings + if include_description: + full_message = ( + f"{alert_props['headline']}\n" + f"Description: {alert_props['description']}" + ) + else: + full_message = alert_props['headline'] + + # Split message into chunks and send + messages = self.split_message(full_message) + + if not self.interface or not hasattr(self.interface, 'sendText'): + logger.error("Interface not properly configured for sending messages") + return + + for i, msg in enumerate(messages, 1): + formatted_msg = f"--({i}/{len(messages)}) Alert--\n{msg}" + self.interface.sendText( + formatted_msg, + wantAck=False, + destinationId='^all' + ) + if i < len(messages): # Don't sleep after last message + time.sleep(self.message_delay) + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to fetch weather alerts: {str(e)}") + except Exception as e: + logger.error(f"Error checking weather alerts: {str(e)}") + + def start_monitoring(self): + """Start continuous monitoring of weather alerts in a separate thread.""" + + def monitor(): + while True: + try: + self.check_alerts() + except Exception as e: + logger.error(f"Error in monitor thread: {str(e)}") + finally: + time.sleep(self.check_interval) + + monitor_thread = threading.Thread(target=monitor, daemon=True) + monitor_thread.start() \ No newline at end of file diff --git a/modules/weather_data_manager.py b/modules/weather_data_manager.py new file mode 100644 index 0000000..a084627 --- /dev/null +++ b/modules/weather_data_manager.py @@ -0,0 +1,64 @@ +import requests +import logging +from datetime import datetime, timedelta + +class WeatherDataManager: + def __init__(self, office="HNX", grid_x="67", grid_y="80", user_agent="(myweatherapp, contact@example.com)"): + self.hourly_url = f"https://api.weather.gov/gridpoints/{office}/{grid_x},{grid_y}/forecast/hourly" + self.daily_url = f"https://api.weather.gov/gridpoints/{office}/{grid_x},{grid_y}/forecast" + self.headers = {"User-Agent": user_agent} + + self.hourly_data = None + self.daily_data = None + self.last_hourly_update = None + self.last_daily_update = None + self.update_interval = timedelta(hours=1) # Update every hour + + def _fetch_hourly_data(self): + try: + response = requests.get(self.hourly_url, headers=self.headers) + if response.status_code == 200: + self.hourly_data = response.json() + self.last_hourly_update = datetime.now() + logging.info("Updated hourly weather data") + return True + else: + logging.error(f"Failed to fetch hourly data: {response.status_code}") + return False + except Exception as e: + logging.error(f"Error fetching hourly weather data: {str(e)}") + return False + + def _fetch_daily_data(self): + try: + response = requests.get(self.daily_url, headers=self.headers) + if response.status_code == 200: + self.daily_data = response.json() + self.last_daily_update = datetime.now() + logging.info("Updated daily weather data") + return True + else: + logging.error(f"Failed to fetch daily data: {response.status_code}") + return False + except Exception as e: + logging.error(f"Error fetching daily weather data: {str(e)}") + return False + + def needs_update(self, last_update): + if last_update is None: + return True + return datetime.now() - last_update > self.update_interval + + def get_hourly_data(self): + if self.needs_update(self.last_hourly_update): + self._fetch_hourly_data() + return self.hourly_data + + def get_daily_data(self): + if self.needs_update(self.last_daily_update): + self._fetch_daily_data() + return self.daily_data + + def force_update(self): + """Force an immediate update of both hourly and daily data""" + return self._fetch_hourly_data() and self._fetch_daily_data() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..00aee77 Binary files /dev/null and b/requirements.txt differ diff --git a/settings.yaml b/settings.yaml new file mode 100644 index 0000000..f5badc1 --- /dev/null +++ b/settings.yaml @@ -0,0 +1,24 @@ +MYNODES: + - "1234567890" + - "1234567890" +DM_MODE: true # if true bot responds to direct messages only. Recommend not changing this +FIREWALL: false # if True only responds to node ids listed under "MYNODES:" +DUTYCYCLE: false # if true will limit to 10% duty cycle +NWS_OFFICE: "HNX" #Location settings for the weather forcast api calls, see readme for details +NWS_GRID_X: "67" +NWS_GRID_Y: "80" +USER_AGENT_APP: "myweatherapp" #used for NWS API calls, can be whatever you want, more unique the better. +USER_AGENT_EMAIL: "contact@example.com" #your email, in the event NWS detects excess api calls they can contact you +ALERT_LAT: "33.5748" #Location settings for alerts, use your coordinates. No more than 4 digits past the decimal point +ALERT_LON: "-104.6314" +ALERT_CHECK_INTERVAL: 300 # Time in seconds between alert checks (default: 300 = 5 minutes) +MESSAGE_DELAY: 7 # Delay between sending split messages +ALERT_INCLUDE_DESCRIPTION: false # Set to false to exclude description from alerts +ENABLE_7DAY_FORECAST: true # Set to false to disable 7-day forecast module +ENABLE_5DAY_FORECAST: true # Set to false to disable 5-day forecast module +ENABLE_HOURLY_WEATHER: true # Set to false to disable hourly weather module +FULL_MENU: true # When true, includes all weather commands. When false, shows only single message options. +ENABLE_AUTO_REBOOT: false # Set to true to enable automatic daily reboot of the connected node +AUTO_REBOOT_HOUR: 3 # Hour for daily reboot (24-hour format) +AUTO_REBOOT_MINUTE: 0 # Minute for daily reboot +REBOOT_DELAY_SECONDS: 10 # Delay in seconds before reboot occurs (recommend not changing this)