Files
meshcore-bot/modules/commands/aqi_command.py
T
agessaman 3a896ec1ea feat(location): enhance geocoding and error handling in location resolution
- Added asynchronous support for location resolution, improving performance and responsiveness.
- Introduced detailed error handling for invalid latitude and longitude inputs, providing specific feedback to users.
- Implemented a caching mechanism for geocoding results to optimize repeated lookups.
- Refactored the AQI command to streamline location handling and improve clarity in error messages.
- Expanded unit tests to cover new geocoding features and error scenarios, ensuring robust functionality.
2026-07-20 09:35:13 -07:00

524 lines
23 KiB
Python

#!/usr/bin/env python3
"""
AQI command for the MeshCore Bot
Provides Air Quality Index information using OpenMeteo API
"""
from typing import Optional
import openmeteo_requests
import requests_cache
from retry_requests import retry
from ..location import (
OPTIONS_AQI,
ResolveOptions,
geocode_city_best_effort,
resolve_location,
)
from ..location import (
get_neighborhood_queries as location_neighborhood_queries,
)
from ..models import MeshMessage
from ..utils import (
abbreviate_location,
get_nominatim_geocoder,
is_valid_timezone,
normalize_us_state,
)
from .base_command import BaseCommand
class AqiCommand(BaseCommand):
"""Handles AQI commands with location support using OpenMeteo API.
Provides Air Quality Index information for specified locations, including
cities, ZIP codes, and coordinates. Supports international locations and
provides health impact categories.
"""
# Plugin metadata
name = "aqi"
keywords = ['aqi', 'air', 'airquality', 'air_quality']
description = "Get Air Quality Index for a location (usage: aqi seattle, aqi greenwood, aqi vancouver canada, aqi 47.6,-122.3, or aqi help)"
category = "weather"
cooldown_seconds = 5 # 5 second cooldown per user to prevent API abuse
requires_internet = True # Requires internet access for OpenMeteo API and geocoding
# Documentation
short_description = "Get Air Quality Index for a location"
usage = "aqi <city|neighborhood|coordinates|help>"
examples = ["aqi seattle", "aqi 47.6,-122.3"]
parameters = [
{"name": "location", "description": "City, neighborhood, lat/lon, or 'help'"}
]
# Error constants
ERROR_FETCHING_DATA = "Error fetching AQI data"
NO_DATA_AVAILABLE = "No AQI data available"
# Web-viewer settings schema (see modules/settings_schema.py).
# These are shared [Weather] defaults used by all weather commands.
settings_schema = [
{"key": "default_state", "label": "Default state", "type": "str", "section": "Weather",
"default": "", "help": "2-letter state for city disambiguation (e.g. WA). Shared weather setting."},
{"key": "default_country", "label": "Default country", "type": "str", "section": "Weather",
"default": "US", "help": "2-letter country code (e.g. US). Shared weather setting."},
]
def __init__(self, bot):
super().__init__(bot)
self.aqi_enabled = self.get_config_value('Aqi_Command', 'enabled', fallback=True, value_type='bool')
self.url_timeout = 10 # seconds
# Get default state and country from config for city disambiguation
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='')
self.default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
# Get timezone from config (validated); invalid or empty falls back to system; for API use default or UTC
timezone_str = self.bot.config.get('Bot', 'timezone', fallback='').strip()
if timezone_str and is_valid_timezone(timezone_str):
self.timezone = timezone_str
else:
if timezone_str:
self.logger.warning("Invalid timezone '%s', using system timezone", timezone_str)
self.timezone = "America/Los_Angeles" if not timezone_str else "UTC"
# Initialize geocoder (will use rate-limited helpers for actual calls)
self.geolocator = get_nominatim_geocoder()
# Get database manager for geocoding cache
self.db_manager = bot.db_manager
# Setup the Open-Meteo API client with cache and retry on error
cache_session = requests_cache.CachedSession('.cache', expire_after=3600)
retry_session = retry(cache_session, retries=5, backoff_factor=0.2)
self.openmeteo = openmeteo_requests.Client(session=retry_session)
# Snarky responses for astronomical objects
self.astronomical_responses = {
'sun': "The Sun's AQI is off the charts! Solar wind and coronal mass ejections make Earth's air look pristine. ☀️",
'moon': "You like breathing regolith? The Moon has no atmosphere, so AQI is technically perfect (if you can breathe vacuum). 🌙",
'the moon': "You like breathing regolith? The Moon has no atmosphere, so AQI is technically perfect (if you can breathe vacuum). 🌙",
'mercury': "Mercury's atmosphere is so thin it's practically vacuum. AQI: Perfect, if you can survive 800°F temperature swings. ☿️",
'venus': "Venus has an atmosphere of 96% CO2 with sulfuric acid clouds. AQI: Hazardous doesn't even begin to describe it. ♀️",
'earth': "Earth's AQI varies by location. Try a specific city or coordinates! 🌍",
'mars': "Mars has a thin CO2 atmosphere with dust storms. AQI: Generally good, but those dust storms are brutal. ♂️",
'jupiter': "Jupiter is a gas giant with no solid surface. AQI: N/A (you'd be crushed by atmospheric pressure first). ♃",
'saturn': "Saturn's atmosphere is mostly hydrogen and helium. AQI: Perfect, if you can survive the pressure and cold. ♄",
'uranus': "Uranus has methane in its atmosphere. AQI: Smells like farts, but at least it's not toxic. ♅",
'neptune': "Neptune's atmosphere has methane and hydrogen sulfide. AQI: Smells like rotten eggs, but you'd freeze first. ♆",
'pluto': "Pluto's atmosphere is mostly nitrogen with some methane. AQI: Good, but it's so cold your lungs would freeze. ♇",
'europa': "Europa has a thin oxygen atmosphere. AQI: Excellent, but you'd freeze solid in the vacuum of space. 🌑",
'titan': "Titan has a thick nitrogen atmosphere with methane. AQI: Breathable, but it's -290°F and rains liquid methane. 🪐",
'io': "Io has a thin sulfur dioxide atmosphere from volcanic activity. AQI: Toxic, but the radiation would kill you first. 🌋",
'ganymede': "Ganymede has a thin oxygen atmosphere. AQI: Good, but you'd freeze in the vacuum of space. 🛸",
'callisto': "Callisto has a thin carbon dioxide atmosphere. AQI: Decent, but it's -220°F and you're in space. ❄️",
'enceladus': "Enceladus has water vapor from geysers. AQI: Perfect, but you'd freeze instantly in space. 💧",
'triton': "Triton has a thin nitrogen atmosphere. AQI: Good, but it's -390°F and you're in deep space. 🥶",
# Bonus fun responses
'space': "Space has no atmosphere, so AQI is perfect! Just don't forget your spacesuit. 🚀",
'void': "The void of space has excellent air quality - zero pollutants! Just remember to bring your own air. 🌌",
'black hole': "Black holes have no atmosphere, but the tidal forces would be a bigger problem than air quality. 🕳️",
'asteroid': "Asteroids have no atmosphere, so AQI is perfect! Just watch out for the vacuum of space. ☄️",
'comet': "Comets have thin atmospheres of water vapor and dust. AQI: Variable, but you'd freeze in space anyway. ☄️"
}
def get_help_text(self) -> str:
region = self.default_state or self.default_country
return f"Usage: aqi <city|neighborhood|city country|lat,lon|help> - Get AQI for city/neighborhood in {region}, intl cities, coordinates, or help"
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
"""Check if this command can be executed with the given message.
Args:
message: The message triggering the command.
Returns:
bool: True if command is enabled and checks pass, False otherwise.
"""
if not self.aqi_enabled:
return False
return super().can_execute(message)
def get_pollutant_help(self) -> str:
"""Get help text explaining pollutant types within 130 characters.
Returns:
str: Compact help string explaining pollutant abbreviations.
"""
# Compact explanation of all pollutants - fits within 130 chars
return "AQI Help: PM2.5=fine particles, PM10=coarse, O3=ozone, NO2=nitrogen dioxide, CO=carbon monoxide, SO2=sulfur dioxide"
async def execute(self, message: MeshMessage) -> bool:
"""Execute the AQI command.
Args:
message: The input message trigger.
Returns:
bool: True if execution was successful.
"""
content = message.content.strip()
# Parse the command to extract location
# Support formats: "aqi seattle", "aqi paris, tx", "aqi 47.6,-122.3", "air everett", "aqi help"
parts = content.split()
if len(parts) < 2:
await self.send_response(message, "Usage: aqi <city|neighborhood|city country|lat,lon> - Example: aqi seattle, aqi greenwood, aqi vancouver canada, or aqi 47.6,-122.3")
return True
# Check for help command
if len(parts) >= 2 and parts[1].lower() == 'help':
help_text = self.get_pollutant_help()
await self.send_response(message, help_text)
return True
# Join all parts after the command to handle "city, state" format
location = ' '.join(parts[1:]).strip()
# Check for astronomical objects first
location_lower = location.lower()
if location_lower in self.astronomical_responses:
await self.send_response(message, self.astronomical_responses[location_lower])
return True
try:
# Record execution for this user
self.record_execution(message.sender_id)
# Get AQI data for the location (single resolve with intl rewrite)
aqi_data = await self.get_aqi_for_location(location)
# Send the response
await self.send_response(message, aqi_data)
return True
except Exception as e:
self.logger.error(f"Error in AQI command: {e}")
await self.send_response(message, f"Error getting AQI data: {e}")
return True
def _resolved_state_differs_from_default(self, address_info: Optional[dict]) -> bool:
"""True when reverse-geocode state/country differs from bot default_state."""
if not address_info or not self.default_state:
return False
country = address_info.get("country", "")
state = address_info.get("state", "")
default_abbr, default_full = normalize_us_state(self.default_state)
defaults = {d for d in (self.default_state, default_abbr, default_full) if d}
if country in ("United States", "US", "United States of America"):
abbr, full = normalize_us_state(state) if state else (None, None)
actuals = {a for a in (abbr, full, state) if a}
return bool(actuals) and actuals.isdisjoint(defaults)
actual_state = country or address_info.get("province") or ""
return bool(actual_state) and actual_state not in defaults
async def get_aqi_for_location(
self, location: str, location_type: Optional[str] = None
) -> str:
"""Get AQI data for a location (city, ZIP, or coordinates).
Args:
location: Raw location string (city name, ZIP, or "lat,lon").
location_type: Unused; kept for call-site/test compatibility.
Returns:
str: Formatted AQI string or error message.
"""
try:
opts = ResolveOptions(
default_state=self.default_state,
default_country=self.default_country,
use_international_cities=OPTIONS_AQI.use_international_cities,
use_neighborhoods=OPTIONS_AQI.use_neighborhoods,
use_structured_zip=OPTIONS_AQI.use_structured_zip,
label_style=OPTIONS_AQI.label_style,
include_address_info=True,
timeout=10,
)
resolved = resolve_location(self.bot, location, options=opts)
if resolved.error == "invalid_latitude":
detail = resolved.error_detail or location
return f"Invalid latitude: {detail}. Must be between -90 and 90."
if resolved.error == "invalid_longitude":
detail = resolved.error_detail or location
return f"Invalid longitude: {detail}. Must be between -180 and 180."
if resolved.error == "invalid_coordinates":
return f"Invalid coordinates format: {location}. Use format: lat,lon (e.g., 47.6,-122.3)"
if resolved.error == "no_location_zipcode":
return f"Could not find ZIP code '{location.strip()}'"
if resolved.error == "no_location_city":
if "," in (resolved.query or location):
return f"Could not find city '{resolved.query or location}'"
region = self.default_state or self.default_country
return f"Could not find city '{resolved.query or location}' in {region}"
if resolved.lat is None or resolved.lon is None:
return f"Could not find location '{location}'"
lat, lon = resolved.lat, resolved.lon
address_info = resolved.address_info
location_type = resolved.location_type or location_type
aqi_data = self.get_openmeteo_aqi(lat, lon)
if aqi_data == self.ERROR_FETCHING_DATA:
return "Error fetching AQI data from OpenMeteo"
location_prefix = ""
if location_type == "coordinates":
location_prefix = f"{lat:.3f},{lon:.3f}: "
elif resolved.display_name:
city_display = resolved.display_name
test_output = f"{city_display}: {aqi_data}"
if len(test_output) <= 130:
location_prefix = f"{city_display}: "
elif location_type == "zipcode":
location_prefix = f"{location.strip()}: "
elif self._resolved_state_differs_from_default(address_info):
# Over budget: only keep a short prefix when outside default region
short = abbreviate_location(city_display, max_length=20)
location_prefix = f"{short}: "
elif location_type == "zipcode":
location_prefix = f"{location.strip()}: "
return f"{location_prefix}{aqi_data}"
except Exception as e:
self.logger.error(f"Error getting AQI for {location_type} {location}: {e}")
return f"Error getting AQI data: {e}"
def city_to_lat_lon(self, city: str) -> tuple:
"""Convert city name to latitude and longitude using default state.
Thin wrapper over shared ``geocode_city_best_effort`` (kept for tests/compat).
"""
lat, lon, address_info = geocode_city_best_effort(
self.bot,
city,
default_state=self.default_state,
default_country=self.default_country,
use_neighborhoods=True,
include_address_info=True,
timeout=10,
)
if lat is None or lon is None:
return (None, None, None)
return lat, lon, address_info or {}
def get_neighborhood_queries(self, city: str) -> list:
"""Generate neighborhood-specific search queries for major cities."""
return location_neighborhood_queries(city)
def get_openmeteo_aqi(self, lat: float, lon: float) -> str:
"""Get AQI data from OpenMeteo API.
Args:
lat: Latitude.
lon: Longitude.
Returns:
str: Formatted AQI string or error constant.
"""
try:
# Make sure all required weather variables are listed here
# The order of variables in current is important to assign them correctly below
url = "https://air-quality-api.open-meteo.com/v1/air-quality"
# OpenMeteo requires a valid IANA timezone; empty config sends "" and returns "Invalid timezone"
tz_for_api = (self.timezone or "UTC").strip() or "UTC"
params = {
"latitude": lat,
"longitude": lon,
"current": ["us_aqi", "european_aqi", "pm10", "pm2_5", "carbon_monoxide", "nitrogen_dioxide", "sulphur_dioxide", "ozone", "dust"],
"timezone": tz_for_api,
"forecast_days": 1,
}
responses = self.openmeteo.weather_api(url, params=params)
# Process first location
response = responses[0]
# Process current data. The order of variables needs to be the same as requested.
current = response.Current()
current_us_aqi = current.Variables(0).Value()
current_european_aqi = current.Variables(1).Value()
current_pm10 = current.Variables(2).Value()
current_pm2_5 = current.Variables(3).Value()
current_carbon_monoxide = current.Variables(4).Value()
current_nitrogen_dioxide = current.Variables(5).Value()
current_sulphur_dioxide = current.Variables(6).Value()
current_ozone = current.Variables(7).Value()
current_dust = current.Variables(8).Value()
# Format the AQI response
return self.format_aqi_response(
current_us_aqi, current_european_aqi, current_pm10, current_pm2_5,
current_carbon_monoxide, current_nitrogen_dioxide, current_sulphur_dioxide,
current_ozone, current_dust
)
except Exception as e:
self.logger.error(f"Error fetching OpenMeteo AQI: {e}")
return self.ERROR_FETCHING_DATA
def format_aqi_response(self, us_aqi, european_aqi, pm10, pm2_5, co, no2, so2, ozone, dust) -> str:
"""Format AQI data for display within 130 characters.
Args:
us_aqi: US Air Quality Index value.
european_aqi: European Air Quality Index value.
pm10: PM10 concentration.
pm2_5: PM2.5 concentration.
co: Carbon Monoxide concentration.
no2: Nitrogen Dioxide concentration.
so2: Sulfur Dioxide concentration.
ozone: Ozone concentration.
dust: Dust concentration.
Returns:
str: Formatted AQI string.
"""
try:
# Start with US AQI as primary
if us_aqi is not None and us_aqi > 0:
aqi_emoji = self.get_aqi_emoji(us_aqi)
aqi_category = self.get_aqi_category(us_aqi)
aqi_str = f"{aqi_emoji} {us_aqi:.0f} ({aqi_category})"
else:
# Fallback to European AQI
if european_aqi is not None and european_aqi > 0:
aqi_emoji = self.get_european_aqi_emoji(european_aqi)
aqi_str = f"{aqi_emoji} {european_aqi:.0f} (EU)"
else:
aqi_str = "🌫️ N/A"
# Add key pollutants if space allows - prioritize by health impact
# Priority order: PM2.5 > PM10 > O3 > NO2 > CO > SO2
pollutants = []
# PM2.5 is most important (fine particles - most harmful to health)
if pm2_5 is not None and pm2_5 > 0:
pollutants.append(f"PM2.5:{pm2_5:.0f}")
# PM10 is second most important (coarse particles)
if pm10 is not None and pm10 > 0 and len(aqi_str + " ".join(pollutants)) < 100:
pollutants.append(f"PM10:{pm10:.0f}")
# Ozone if space allows (ground-level ozone - respiratory irritant)
if ozone is not None and ozone > 0 and len(aqi_str + " ".join(pollutants)) < 110:
pollutants.append(f"O3:{ozone:.0f}")
# NO2 if space allows (nitrogen dioxide - respiratory irritant)
if no2 is not None and no2 > 0 and len(aqi_str + " ".join(pollutants)) < 120:
pollutants.append(f"NO2:{no2:.0f}")
# CO if space allows (carbon monoxide - toxic gas)
if co is not None and co > 0 and len(aqi_str + " ".join(pollutants)) < 125:
pollutants.append(f"CO:{co:.0f}")
# SO2 if space allows (sulfur dioxide - respiratory irritant)
if so2 is not None and so2 > 0 and len(aqi_str + " ".join(pollutants)) < 130:
pollutants.append(f"SO2:{so2:.0f}")
# Combine everything
result = f"{aqi_str} {' '.join(pollutants)}" if pollutants else aqi_str
# Ensure we don't exceed 130 characters
if len(result) > 130:
# Try with fewer pollutants - remove least important ones first
pollutants = []
if pm2_5 is not None and pm2_5 > 0:
pollutants.append(f"PM2.5:{pm2_5:.0f}")
if pm10 is not None and pm10 > 0:
pollutants.append(f"PM10:{pm10:.0f}")
if ozone is not None and ozone > 0:
pollutants.append(f"O3:{ozone:.0f}")
if no2 is not None and no2 > 0:
pollutants.append(f"NO2:{no2:.0f}")
result = f"{aqi_str} {' '.join(pollutants)}"
# If still too long, try with just the most important
if len(result) > 130:
pollutants = []
if pm2_5 is not None and pm2_5 > 0:
pollutants.append(f"PM2.5:{pm2_5:.0f}")
if pm10 is not None and pm10 > 0:
pollutants.append(f"PM10:{pm10:.0f}")
result = f"{aqi_str} {' '.join(pollutants)}"
# If still too long, just return the AQI
if len(result) > 130:
result = aqi_str
return result
except Exception as e:
self.logger.error(f"Error formatting AQI response: {e}")
return "Error formatting AQI data"
def get_aqi_emoji(self, aqi: float) -> str:
"""Get emoji for US AQI value.
Args:
aqi: US AQI value.
Returns:
str: Emoji representing AQI level (🟢, 🟡, 🟠, 🔴, 🟣, 🟤).
"""
if aqi <= 50:
return "🟢" # Good
elif aqi <= 100:
return "🟡" # Moderate
elif aqi <= 150:
return "🟠" # Unhealthy for Sensitive Groups
elif aqi <= 200:
return "🔴" # Unhealthy
elif aqi <= 300:
return "🟣" # Very Unhealthy
else:
return "🟤" # Hazardous
def get_european_aqi_emoji(self, aqi: float) -> str:
"""Get emoji for European AQI value.
Args:
aqi: European AQI value.
Returns:
str: Emoji representing European AQI level.
"""
if aqi <= 25:
return "🟢" # Good
elif aqi <= 50:
return "🟡" # Fair
elif aqi <= 75:
return "🟠" # Moderate
elif aqi <= 100:
return "🔴" # Poor
else:
return "🟣" # Very Poor
def get_aqi_category(self, aqi: float) -> str:
"""Get category name for US AQI value.
Args:
aqi: US AQI value.
Returns:
str: Category description (e.g., "Good", "Moderate").
"""
if aqi <= 50:
return "Good"
elif aqi <= 100:
return "Moderate"
elif aqi <= 150:
return "Unhealthy for Sensitive Groups"
elif aqi <= 200:
return "Unhealthy"
elif aqi <= 300:
return "Very Unhealthy"
else:
return "Hazardous"