Initial upload
318
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 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)
|
||||