Initial upload
318
README.md
@ -1,2 +1,316 @@
|
|||||||
# Meshbot_weather
|
# MeshBot Weather
|
||||||
A weather bot with alerts and forecast. Designed to run on a Raspberry pi with a connected Meshtastic radio.
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
BIN
img/2day.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
img/4day.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
img/5day.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
img/Grid.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
img/alertstatus.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
img/help.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
img/hourly.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
img/menu.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
img/meshbot_weather.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
img/rain.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
img/temp.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
505
meshbot.py
Normal file
@ -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()
|
||||||
0
modules/__init__.py
Normal file
BIN
modules/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
modules/__pycache__/forecast_2day.cpython-311.pyc
Normal file
BIN
modules/__pycache__/forecast_4day.cpython-311.pyc
Normal file
BIN
modules/__pycache__/forecast_5day.cpython-311.pyc
Normal file
BIN
modules/__pycache__/forecast_7day.cpython-311.pyc
Normal file
BIN
modules/__pycache__/hourly_weather.cpython-311.pyc
Normal file
BIN
modules/__pycache__/rain_24hour.cpython-311.pyc
Normal file
BIN
modules/__pycache__/temperature_24hour.cpython-311.pyc
Normal file
BIN
modules/__pycache__/weather_alert_monitor.cpython-311.pyc
Normal file
BIN
modules/__pycache__/weather_data_manager.cpython-311.pyc
Normal file
51
modules/forecast_2day.py
Normal file
@ -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
|
||||||
125
modules/forecast_4day.py
Normal file
@ -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]
|
||||||
65
modules/forecast_5day.py
Normal file
@ -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]
|
||||||
131
modules/forecast_7day.py
Normal file
@ -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
|
||||||
93
modules/hourly_weather.py
Normal file
@ -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
|
||||||
50
modules/rain_24hour.py
Normal file
@ -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)}"
|
||||||
51
modules/temperature_24hour.py
Normal file
@ -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)}"
|
||||||
155
modules/weather_alert_monitor.py
Normal file
@ -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()
|
||||||
64
modules/weather_data_manager.py
Normal file
@ -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()
|
||||||
BIN
requirements.txt
Normal file
24
settings.yaml
Normal file
@ -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)
|
||||||