Files
meshcore-bot/modules/commands/aurora_command.py
T
Stacy Olivas 4979687299 fix: auth, db migrations, retry, chunking, socket race, trace, timezone, repeater, and ruff/mypy cleanup
BUG-001: web viewer login/session auth (in web viewer commit)
BUG-002: db_manager ALTER TABLE for missing channel_operations and
  feed_message_queue columns on startup
BUG-015: scheduler thread blocked on future.result(); replaced all
  blocking waits with add_done_callback (fire-and-forget)
BUG-016: reboot_radio sends meshcore.commands.reboot() before disconnect
BUG-017: radio disconnect uses asyncio.wait_for(timeout=10)
BUG-022: custom asyncio loop exception handler suppresses IndexError
  from meshcore parser at DEBUG level
BUG-024: last_db_backup_run updated after each run; 2-min startup
  window; last-run seeded from DB on restart
BUG-025: send_channel_message retries up to 2 times (2s delay) on
  no_event_received via _is_no_event_received() helper
BUG-026: split_text_into_chunks() and get_max_message_length() added
  to CommandManager; keyword dispatch uses send_response_chunked()
BUG-028: byte_data = b"" initialised before try block in
  decode_meshcore_packet to prevent UnboundLocalError in except handler
TraceCommand: path nodes reversed and return path truncated; fixed
format_elapsed_display: UTC normalisation before elapsed computation (#75)
RepeaterManager: auto_manage_contacts guard before any purge logic (#50)
Command aliases: [Aliases] config section injects shorthands at startup
JSON logging: _JsonFormatter; json_logging = true in [Logging]
Structured JSON logging compatible with Loki, Elasticsearch, Splunk
Discord bridge, Telegram bridge, and all service plugins updated
MeshGraph edge promotion logic corrected
Shutdown: scheduler and meshcore disconnect joined cleanly; log spam fixed
All modules: ruff and mypy cleanup applied (type annotations, imports)
2026-03-17 17:44:47 -07:00

262 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Aurora command - NOAA KP index and Ovation aurora probability for a location.
"""
import asyncio
import re
from datetime import datetime, timezone
from typing import Optional
from ..clients.noaa_aurora_client import NOAAAuroraClient
from ..models import MeshMessage
from ..utils import geocode_city_sync, geocode_zipcode_sync, get_config_timezone
from .base_command import BaseCommand
class AuroraCommand(BaseCommand):
"""Command to get aurora (KP index and probability) for a location."""
name = "aurora"
keywords = ["aurora", "kp"]
description = "Get aurora forecast (KP index and probability) for a location"
category = "solar"
requires_internet = True
cooldown_seconds = 5
short_description = "Get aurora forecast (KP index and probability) for a location"
usage = "aurora [city|zipcode|lat,lon]"
examples = ["aurora", "aurora seattle", "aurora 98101", "aurora 48.08,-121.97"]
parameters = [
{"name": "location", "description": "Optional: city, US ZIP, or lat,lon. Default: config or companion location."}
]
def __init__(self, bot):
super().__init__(bot)
self.aurora_enabled = self.get_config_value("Aurora_Command", "enabled", fallback=True, value_type="bool")
self.default_state = self.bot.config.get("Weather", "default_state", fallback="")
self.default_country = self.bot.config.get("Weather", "default_country", fallback="US")
self.url_timeout = 10
def can_execute(self, message: MeshMessage) -> bool:
if not self.aurora_enabled:
return False
return super().can_execute(message)
def _get_companion_location(self, message: MeshMessage) -> Optional[tuple[float, float]]:
"""Get companion/sender location from database."""
try:
sender_pubkey = getattr(message, "sender_pubkey", None)
if not sender_pubkey:
return None
query = """
SELECT latitude, longitude
FROM complete_contact_tracking
WHERE public_key = ?
AND latitude IS NOT NULL AND longitude IS NOT NULL
AND latitude != 0 AND longitude != 0
ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC
LIMIT 1
"""
results = self.bot.db_manager.execute_query(query, (sender_pubkey,))
if results:
row = results[0]
return (float(row["latitude"]), float(row["longitude"]))
return None
except Exception as e:
self.logger.debug(f"Error getting companion location: {e}")
return None
def _get_bot_location(self) -> Optional[tuple[float, float]]:
"""Get bot location from config ([Bot] bot_latitude, bot_longitude)."""
try:
lat = self.bot.config.getfloat("Bot", "bot_latitude", fallback=None)
lon = self.bot.config.getfloat("Bot", "bot_longitude", fallback=None)
if lat is not None and lon is not None and -90 <= lat <= 90 and -180 <= lon <= 180:
return (lat, lon)
return None
except Exception as e:
self.logger.debug(f"Error getting bot location: {e}")
return None
# 8-level block meter for visibility chance (▁ low → █ high), not stoplight
_PROB_INDICATORS = "▁▂▃▄▅▆▇█"
def _prob_indicator(self, prob_pct: int) -> str:
"""Return a one-char bar (▁→█) for probability 0100%."""
idx = min(7, max(0, int(prob_pct / 12.5))) if prob_pct else 0
return self._PROB_INDICATORS[idx]
def _format_kp_time(self, ts: str) -> str:
"""Format NOAA Kp time_tag (UTC) to compact form in local time or Zulu.
Supports 1m product ISO format (e.g. "2026-01-21T05:13:00") and legacy
space-separated formats. Uses [Bot] timezone when set; if [Solar_Config]
use_zulu_time is true or no Bot timezone, shows UTC with Z. Otherwise local.
"""
if not ts or not ts.strip():
return ""
s = ts.strip()
dt_utc = None
for fmt in (
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M",
):
try:
dt = datetime.strptime(s, fmt)
dt_utc = dt.replace(tzinfo=timezone.utc)
break
except ValueError:
continue
if dt_utc is None and ("T" in s or "Z" in s):
try:
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
dt_utc = dt.astimezone(timezone.utc) if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except ValueError:
pass
if dt_utc is None:
return ""
use_zulu = self.get_config_value("Solar_Config", "use_zulu_time", fallback=False, value_type="bool")
tz, iana_str = get_config_timezone(self.bot.config, self.logger)
if use_zulu or iana_str == "UTC":
return dt_utc.strftime("%b %d ") + f"{dt_utc.hour:02d}Z"
local = dt_utc.astimezone(tz)
return local.strftime("%b %d %I:%M%p")
def _resolve_location(
self, message: MeshMessage, location: Optional[str]
) -> tuple[Optional[float], Optional[float], Optional[str], Optional[str]]:
"""
Resolve to (lat, lon, location_label, error_key).
location_label is for display; error_key is a translation key if resolution failed.
"""
# 1. No user input: companion, then [Aurora_Command] default, then bot location
if not location or not location.strip():
co = self._get_companion_location(message)
if co:
return (co[0], co[1], f"{co[0]:.1f},{co[1]:.1f}", None)
default_lat = default_lon = None
if self.bot.config.has_section("Aurora_Command"):
default_lat = self.bot.config.getfloat("Aurora_Command", "default_lat", fallback=None)
default_lon = self.bot.config.getfloat("Aurora_Command", "default_lon", fallback=None)
if default_lat is not None and default_lon is not None:
if -90 <= default_lat <= 90 and -180 <= default_lon <= 180:
label = f"{default_lat:.1f},{default_lon:.1f}"
return (default_lat, default_lon, label, None)
bot_loc = self._get_bot_location()
if bot_loc:
return (bot_loc[0], bot_loc[1], f"{bot_loc[0]:.1f},{bot_loc[1]:.1f}", None)
return (None, None, None, "commands.aurora.no_location")
loc = location.strip()
# 2. Coordinates
if re.match(r"^\s*-?\d+\.?\d*\s*,\s*-?\d+\.?\d*\s*$", loc):
try:
a, b = loc.split(",", 1)
lat, lon = float(a.strip()), float(b.strip())
if not (-90 <= lat <= 90):
return (None, None, None, "commands.aurora.error") # pass error via translate with error=...
if not (-180 <= lon <= 180):
return (None, None, None, "commands.aurora.error")
return (lat, lon, loc, None)
except ValueError:
return (None, None, None, "commands.aurora.error")
# 3. ZIP (5 digits)
if re.match(r"^\s*\d{5}\s*$", loc):
lat, lon = geocode_zipcode_sync(
self.bot, loc, default_country=self.default_country, timeout=self.url_timeout
)
if lat is None or lon is None:
return (None, None, None, "commands.aurora.no_location_zipcode")
return (lat, lon, loc, None)
# 4. City
lat, lon, _ = geocode_city_sync(
self.bot,
loc,
default_state=self.default_state,
default_country=self.default_country,
include_address_info=False,
timeout=self.url_timeout,
)
if lat is None or lon is None:
return (None, None, None, "commands.aurora.no_location_city") # needs location, state
return (lat, lon, loc, None)
async def execute(self, message: MeshMessage) -> bool:
content = message.content.strip()
if content.startswith("!"):
content = content[1:].strip()
parts = content.split()
location: Optional[str] = None
if len(parts) >= 2:
location = " ".join(parts[1:]).strip()
lat, lon, location_label, err_key = self._resolve_location(message, location)
if lat is None or lon is None:
region = self.default_state or self.default_country
if err_key == "commands.aurora.no_location":
await self.send_response(message, self.translate("commands.aurora.no_location"))
elif err_key == "commands.aurora.no_location_zipcode":
await self.send_response(
message, self.translate("commands.aurora.no_location_zipcode", location=location or "")
)
elif err_key == "commands.aurora.no_location_city":
await self.send_response(
message,
self.translate("commands.aurora.no_location_city", location=location or "", state=region),
)
else:
await self.send_response(
message, self.translate("commands.aurora.error", error="Invalid location or coordinates")
)
return True
try:
self.record_execution(message.sender_id)
loop = asyncio.get_event_loop()
client = NOAAAuroraClient(latitude=lat, longitude=lon)
data = await loop.run_in_executor(None, lambda: client.get_aurora_data())
except Exception as e:
self.logger.error(f"Error fetching aurora data: {e}")
await self.send_response(
message, self.translate("commands.aurora.error_fetching")
)
return True
# KP -> status (short labels for one-line response)
kp = data.kp_index
if kp >= 7:
status = self.translate("commands.aurora.status.g3_severe")
elif kp >= 5:
status = self.translate("commands.aurora.status.g1_g2")
elif kp >= 4:
status = self.translate("commands.aurora.status.unsettled")
else:
status = self.translate("commands.aurora.status.quiet")
kp_time_str = self._format_kp_time(data.kp_timestamp)
prob_pct = int(round(data.aurora_probability))
prob_indicator = self._prob_indicator(prob_pct)
response = self.translate(
"commands.aurora.response",
kp=f"{data.kp_index:.1f}",
kp_time=kp_time_str,
prob=prob_pct,
prob_indicator=prob_indicator,
location=location_label or f"{lat:.1f},{lon:.1f}",
status=status,
)
max_len = self.get_max_message_length(message)
if len(response) > max_len:
response = response[: max_len - 3] + "..."
await self.send_response(message, response)
return True