Merge 24f329d07e4785a7846ae7c6590a670ce6bef62e into 295fb35c92e376367b05a6429c481191baa23d17

This commit is contained in:
typicalaimster 2025-04-03 13:46:04 -07:00 committed by GitHub
commit e262987f7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 441 additions and 3 deletions

4
.gitignore vendored
View File

@ -1,8 +1,8 @@
__pycache__/
bulletins.db
js8call.db
*.db
venv/
.venv
.idea
config.ini
*/*/*.ini
fortunes.txt

View File

@ -4,6 +4,7 @@ import random
import time
from meshtastic import BROADCAST_NUM
from plugins.weather_plugin.menu import get_weather_menu_text
from db_operations import (
add_bulletin, add_mail, delete_mail,
@ -24,6 +25,7 @@ config.read('config.ini')
main_menu_items = config['menu']['main_menu_items'].split(',')
bbs_menu_items = config['menu']['bbs_menu_items'].split(',')
utilities_menu_items = config['menu']['utilities_menu_items'].split(',')
plugin_menu_items = config['menu']['plugin_menu_items'].split(',')
def build_menu(items, menu_name):
@ -38,6 +40,8 @@ def build_menu(items, menu_name):
menu_str += "[B]BS\n"
elif item.strip() == 'U':
menu_str += "[U]tilities\n"
elif item.strip() == 'P':
menu_str += "[P]lugins\n"
elif item.strip() == 'X':
menu_str += "E[X]IT\n"
elif item.strip() == 'M':
@ -52,6 +56,8 @@ def build_menu(items, menu_name):
menu_str += "[F]ortune\n"
elif item.strip() == 'W':
menu_str += "[W]all of Shame\n"
elif item.strip() == 'WE':
menu_str += get_weather_menu_text()
return menu_str
def handle_help_command(sender_id, interface, menu_name=None):
@ -61,6 +67,8 @@ def handle_help_command(sender_id, interface, menu_name=None):
response = build_menu(bbs_menu_items, "📰BBS Menu📰")
elif menu_name == 'utilities':
response = build_menu(utilities_menu_items, "🛠Utilities Menu🛠")
elif menu_name == 'plugins':
response = build_menu(plugin_menu_items, "🔌Plugins Menu🔌")
else:
update_user_state(sender_id, {'command': 'MAIN_MENU', 'step': 1}) # Reset to main menu state
mail = get_mail(get_node_id_from_num(sender_id, interface))

View File

@ -81,6 +81,7 @@ bbs_menu_items = M, B, C, J, X
# Remove any menu items from the list below that you want to exclude from the utilities menu
utilities_menu_items = S, F, W, X
plugin_menu_items = WE
##########################
#### JS8Call Settings ####

View File

@ -10,14 +10,21 @@ from command_handlers import (
handle_check_bulletin_command, handle_read_bulletin_command, handle_read_channel_command,
handle_post_channel_command, handle_list_channels_command, handle_quick_help_command
)
from plugins.weather_plugin.command_router import handle_weather_command, handle_weather_steps
from db_operations import add_bulletin, add_mail, delete_bulletin, delete_mail, get_db_connection, add_channel
from js8call_integration import handle_js8call_command, handle_js8call_steps, handle_group_message_selection
from utils import get_user_state, get_node_short_name, get_node_id_from_num, send_message
from utils import get_node_short_name, get_node_id_from_num, send_message, get_user_state, update_user_state
main_menu_handlers = {
"q": handle_quick_help_command,
"b": lambda sender_id, interface: handle_help_command(sender_id, interface, 'bbs'),
"u": lambda sender_id, interface: handle_help_command(sender_id, interface, 'utilities'),
"p": lambda sender_id, interface: handle_help_command(sender_id, interface, 'plugins'),
"x": handle_help_command
}
plugins_menu_handlers = {
"we": handle_weather_command,
"x": handle_help_command
}
@ -109,6 +116,8 @@ def process_message(sender_id, message, interface, is_sync_message=False):
handlers = bbs_menu_handlers
elif menu_name == 'utilities':
handlers = utilities_menu_handlers
elif menu_name == 'plugins':
handlers = plugins_menu_handlers
else:
handlers = main_menu_handlers
elif state and state['command'] == 'BULLETIN_MENU':
@ -166,6 +175,8 @@ def process_message(sender_id, message, interface, is_sync_message=False):
handle_bb_steps(sender_id, message, 5, state, interface, bbs_nodes)
elif command == 'BULLETIN_READ':
handle_bb_steps(sender_id, message, 3, state, interface, bbs_nodes)
elif command == 'WEATHER':
handle_weather_steps(sender_id, message, step, interface)
elif command == 'JS8CALL_MENU':
handle_js8call_steps(sender_id, message, step, interface, state)
elif command == 'GROUP_MESSAGES':

0
plugins/__init__.py Normal file
View File

View File

@ -0,0 +1,83 @@
# Weather Plugin Documentation
## Overview
The Weather Plugin provides weather information for the TC² BBS system using the OpenWeather API. It allows users to query current weather conditions by ZIP code. The plugin is accessible through the Plugins Menu in the BBS system.
## Structure
```
weather_plugin/
├── __init__.py # Plugin exports and initialization
├── weather.py # Core weather functionality and API interaction
├── command_router.py # Command handling and user interaction
├── menu.py # Menu text and options
└── weather.ini # Configuration file
```
## Components
### weather.py
Core functionality:
- OpenWeather API integration
- Rate limiting (per minute and daily)
- HTTP request handling with retries
- Temperature conversion (Kelvin to Fahrenheit)
- Weather data formatting
Key features:
- Configurable API rate limits
- Robust error handling
- Request retries for reliability
- Session management for connection reuse
### command_router.py
User interaction:
- Handles weather command from main menu
- Processes ZIP code input
- Manages user state
- Returns formatted weather responses
### menu.py
Menu integration:
- Provides menu text for weather option
- Defines menu item identifier
### Configuration (weather.ini)
Settings:
- OpenWeather API key
- Rate limiting parameters:
- Max calls per minute
- Max calls per day
- Plugin enabled/disabled flag
## Menu Integration
The Weather Plugin is integrated into the TC² BBS system through the Plugins Menu:
- Located in the main menu under [P]lugins
- Identified by "WE" in the plugins menu
- Configurable through the plugin_menu_items setting in config.ini
## Usage Flow
1. User selects [P]lugins from main menu
2. User selects "[WE]ather" from plugins menu
3. System prompts for ZIP code
4. User enters 5-digit ZIP code
5. System checks rate limits
6. Makes API request if within limits
7. Returns formatted weather data:
- Temperature
- Feels like temperature
- Humidity
- Weather conditions
- City name
8. Returns to plugins menu after displaying weather
## Error Handling
- Invalid ZIP codes
- API rate limiting
- Network timeouts
- Missing configuration
- API errors
## Dependencies
- requests: HTTP client library
- configparser: Configuration file handling
- utils: BBS system utilities

View File

@ -0,0 +1,9 @@
from .command_router import handle_weather_command, handle_weather_steps
from .menu import get_weather_menu_item, get_weather_menu_text
__all__ = [
'handle_weather_command',
'handle_weather_steps',
'get_weather_menu_item',
'get_weather_menu_text'
]

View File

@ -0,0 +1,29 @@
from utils import send_message, update_user_state
from .weather import get_weather_by_zip, format_weather_response
def handle_weather_command(sender_id, interface):
"""Handle the weather command from the main menu."""
response = "🌡️ Weather Service 🌡️\n\nEnter a ZIP code (89115) to get the current weather, or 'X' to exit:"
send_message(response, sender_id, interface)
update_user_state(sender_id, {'command': 'WEATHER', 'step': 1})
def handle_weather_steps(sender_id, message, step, interface):
"""Handle the steps for getting weather information."""
message = message.strip().upper()
if message == 'X':
from command_handlers import handle_help_command
handle_help_command(sender_id, interface)
return
if step == 1:
if not message.isdigit() or len(message) != 5:
send_message("Please enter a valid 5-digit ZIP code, or 'X' to exit.", sender_id, interface)
return
weather_data = get_weather_by_zip(message)
response = format_weather_response(weather_data)
send_message(response, sender_id, interface)
from command_handlers import handle_help_command
handle_help_command(sender_id, interface)

View File

@ -0,0 +1,7 @@
def get_weather_menu_item():
"""Return the weather menu item for the main menu."""
return "WE"
def get_weather_menu_text():
"""Return the weather menu text."""
return "[WE]ather\n"

View File

@ -0,0 +1,12 @@
##########################
#### OpenWeather API ####
##########################
# API key and rate limiting settings for the OpenWeather API
# You can get one here.. https://openweathermap.org/api
[openweather]
api_key = YOUR API KEY HERE
# Maximum number of API calls per minute (default: 30)
max_calls_per_minute = 30
# Maximum number of API calls per day (default: 500)
max_calls_per_day = 500
enabled = true

View File

@ -0,0 +1,154 @@
"""
Weather Module for TC² BBS
This module provides weather information using the OpenWeather API.
"""
import logging
import os
import requests
import time
from datetime import datetime, timedelta
from configparser import ConfigParser
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# OpenWeather API configuration
config = ConfigParser()
config_path = os.path.join(os.path.dirname(__file__), 'weather.ini')
if not os.path.exists(config_path):
logging.error(f"Weather configuration file not found at {config_path}")
raise FileNotFoundError(f"Weather configuration file not found at {config_path}")
try:
config.read(config_path)
except Exception as e:
logging.error(f"Error reading weather configuration: {e}")
raise
OPENWEATHER_API_KEY = config.get('openweather', 'api_key', fallback='')
WEATHER_API_URL = "http://api.openweathermap.org/data/2.5/weather"
# Rate limiting configuration
MAX_CALLS_PER_MINUTE = int(config.get('openweather', 'max_calls_per_minute', fallback='30'))
MAX_CALLS_PER_DAY = int(config.get('openweather', 'max_calls_per_day', fallback='500'))
# Create a session object for better connection handling
session = requests.Session()
retry_strategy = Retry(
total=3, # number of retries
backoff_factor=1, # wait 1, 2, 4 seconds between retries
status_forcelist=[500, 502, 503, 504] # HTTP status codes to retry on
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
# Rate limiting state
minute_calls = 0
day_calls = 0
last_minute = datetime.now().replace(second=0, microsecond=0)
last_day = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
def check_rate_limits():
"""
Check if we're within rate limits. Updates counters and resets them when needed.
Returns:
bool: True if we can make a call, False if we've hit a limit
"""
global minute_calls, day_calls, last_minute, last_day
now = datetime.now()
current_minute = now.replace(second=0, microsecond=0)
current_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Reset minute counter if we're in a new minute
if current_minute > last_minute:
minute_calls = 0
last_minute = current_minute
# Reset day counter if we're in a new day
if current_day > last_day:
day_calls = 0
last_day = current_day
# Check if we've hit any limits
if minute_calls >= MAX_CALLS_PER_MINUTE:
logging.warning(f"Weather API minute limit reached ({MAX_CALLS_PER_MINUTE} calls)")
return False
if day_calls >= MAX_CALLS_PER_DAY:
logging.warning(f"Weather API daily limit reached ({MAX_CALLS_PER_DAY} calls)")
return False
# We're good to make a call
minute_calls += 1
day_calls += 1
return True
def kelvin_to_fahrenheit(kelvin):
"""Convert Kelvin to Fahrenheit."""
return (kelvin - 273.15) * 9/5 + 32
def get_weather_by_zip(zip_code, country_code='US'):
"""
Get weather information from OpenWeather API by zip code.
Args:
zip_code: ZIP code to get weather for
country_code: Country code (default: US)
Returns:
dict: Weather data if successful, None if failed
"""
if not OPENWEATHER_API_KEY:
logging.error("OpenWeather API key not configured")
return None
# Check rate limits before making the call
if not check_rate_limits():
logging.error("Weather API rate limit exceeded")
return None
try:
params = {
'zip': f"{zip_code},{country_code}",
'appid': OPENWEATHER_API_KEY
}
response = session.get(WEATHER_API_URL, params=params, timeout=5)
response.raise_for_status()
return response.json()
except requests.Timeout:
logging.error(f"Weather API request timed out after 5 seconds for zip code {zip_code}")
return {"error": "Request timed out - weather service is taking too long to respond"}
except requests.exceptions.RequestException as e:
logging.error(f"Error fetching weather data: {e}")
return None
def format_weather_response(weather_data):
"""Format weather data into a user-friendly message."""
if not weather_data:
return "❌ Failed to get weather data."
if "error" in weather_data:
return f"{weather_data['error']}"
try:
temp = kelvin_to_fahrenheit(weather_data['main']['temp'])
feels_like = kelvin_to_fahrenheit(weather_data['main']['feels_like'])
humidity = weather_data['main']['humidity']
description = weather_data['weather'][0]['description'].capitalize()
city = weather_data['name']
response = f"🌡️ Weather for {city} 🌡️\n\n"
response += f"Temperature: {temp:.1f}°F\n"
response += f"Feels like: {feels_like:.1f}°F\n"
response += f"Humidity: {humidity}%\n"
response += f"Conditions: {description}\n"
return response
except (KeyError, IndexError) as e:
logging.error(f"Error formatting weather data: {e}")
return "❌ Error processing weather data"

124
test_client.py Normal file
View File

@ -0,0 +1,124 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "meshtastic",
# "pypubsub" ]
# ///
# You can use Astral UV to use this script.
# https://docs.astral.sh/uv/getting-started/installation/
# Then simply run uv run test_client.py
import logging
import time
import uuid
from datetime import datetime
from command_handlers import handle_help_command
from db_operations import get_db_connection, initialize_database
from message_processing import process_message
from utils import get_user_state, send_message
class MockInterface:
def __init__(self):
# Initialize with test nodes that match the BBS system structure
self.nodes = {
"!test1": {
"num": 1234,
"user": {"shortName": "TEST1", "longName": "Test Node 1"},
"fromId": "!test1"
},
"!test2": {
"num": 5678,
"user": {"shortName": "TEST2", "longName": "Test Node 2"},
"fromId": "!test2"
}
}
# BBS nodes for syncing bulletins and mail
self.bbs_nodes = ["!test1", "!test2"]
self.messages_sent = []
# Initialize database for testing
initialize_database()
class MockResponse:
def __init__(self, id):
self.id = id
def sendText(self, text, destinationId, wantAck=True, wantResponse=False):
message = {
"text": text,
"destinationId": destinationId,
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"id": str(uuid.uuid4())
}
self.messages_sent.append(message)
return self.MockResponse(message['id'])
def get_sent_messages(self):
return self.messages_sent
def run_interactive_test():
# Initialize test environment
interface = MockInterface()
test_user_id = "!test1"
def process_and_show_response(command):
# Create a packet similar to what Meshtastic would send
packet = {
'from': interface.nodes[test_user_id]['num'], # The sender's node number
'fromId': test_user_id, # The sender's node ID
'decoded': {
'portnum': 'TEXT_MESSAGE_APP',
'payload': command.encode('utf-8')
}
}
# Process the message using the packet
process_message(packet['from'], command, interface)
print("\nSystem Response:")
for msg in interface.get_sent_messages():
print(f"{msg['text']}")
interface.messages_sent.clear()
print("\n" + "-" * 50)
print("\n=== Interactive BBS Test Client ===")
print("You are logged in as TEST1 (Node ID: !test1)")
print("\nAvailable Commands:")
print("- 'x' for main menu")
print("- Quick commands:")
print(" * SM,,recipient,subject,message - Send mail")
print(" * PB,,board,subject,message - Post bulletin")
print("- 'quit' to exit")
print("-" * 50)
# Show initial menu
process_and_show_response("x")
while True:
try:
command = input("\nEnter command: ").strip()
if command.lower() == 'quit':
print("\nExiting test client...")
break
process_and_show_response(command)
except KeyboardInterrupt:
print("\nExiting test client...")
break
except Exception as e:
print(f"Error: {str(e)}")
if __name__ == "__main__":
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
try:
run_interactive_test()
except Exception as e:
logging.error(f"Test failed with error: {str(e)}")
print("\nTest client exited with error.")