mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-01 16:48:26 +00:00
Correctness:
- {path_distance} was always blank in production. The resolution code that
builds repeater_info dropped latitude/longitude in every branch, so the
calculator could never find a coordinate. My tests passed hand-built
dicts straight to the calculator and never exercised the builder, which
is why they stayed green. Coordinates are now carried through all four
construction sites, and the new tests drive _lookup_repeater_names via
its lookup_func hook so the real builder runs.
- The #80 route guard was defeated two ways in the channel handler. When
the RF data was an uncorrelated fallback, control fell through to the
raw-hex and routing_info fallbacks below, which took the route from the
unrelated packet anyway; the guard had actually made that path
reachable. message.routing_info was also assigned unconditionally, and
the path command reads it. "Not attributable" is now a terminal branch
and the routing_info hand-off checks provenance.
- Same fix was incomplete for DMs: routing_info was captured and turned
into path_info before the provenance check ran, so the later check only
declined to overwrite an already-wrong value. Guarded at the source.
- Rendering could transmit for real. Capture only intercepts
send_response, but advert calls send_advert() directly and
send_response_chunked never checked capture_sink. Chunked sends are now
captured, and rendering is opt-in via BaseCommand.render_safe (default
False) instead of a denylist that cannot be complete. This also closes
the DM-only leak: schedule is not marked safe, so {cmd:schedule} can no
longer broadcast configuration to a channel.
- Multi-part rendered output was rejoined into one oversized send.
Scheduled messages are now split to the RF body budget and sent through
send_channel_messages_chunked, on character boundaries so multi-byte
text is not corrupted.
- _last_path_distance_km is instance state that was only set on success,
so an invalid path request could show the previous request's distance.
Reset at the start of every execute().
- Stale-contact retries were only counted on non-OK results, so timeouts
and exceptions left a contact eligible forever and could recreate the
storm. All failed attempts count now.
Web viewer:
- A failed config reload was reported as a successful save. The API and
UI now distinguish "saved and active" from "saved, restart needed".
- Preview count was unbounded; clamped to 1-20.
- update_ini_values is a read-modify-replace with no locking, so two
concurrent viewer saves could lose one. Serialised behind a lock.
288 lines
13 KiB
Python
288 lines
13 KiB
Python
#!/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."""
|
||
|
||
# Read-only informational output; safe for scheduled {cmd:...} rendering.
|
||
|
||
render_safe = True
|
||
|
||
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."}
|
||
]
|
||
|
||
# Web-viewer settings schema (see modules/settings_schema.py).
|
||
# Falls back to [Bot] bot_latitude/longitude when these are unset.
|
||
settings_schema = [
|
||
{"key": "default_lat", "label": "Default latitude", "type": "float",
|
||
"min": -90, "max": 90, "default": "",
|
||
"help": "Latitude used when no location is given (overrides bot location)."},
|
||
{"key": "default_lon", "label": "Default longitude", "type": "float",
|
||
"min": -180, "max": 180, "default": "",
|
||
"help": "Longitude used when no location is given (overrides bot location)."},
|
||
{"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."},
|
||
{"key": "use_zulu_time", "label": "Use UTC (Zulu) time", "type": "bool", "section": "Solar_Config",
|
||
"default": False, "help": "On = 24-hour UTC times; off = 12-hour local. Shared solar setting."},
|
||
]
|
||
|
||
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, skip_channel_check: bool = False) -> 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 0–100%."""
|
||
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()
|
||
|
||
# Offloaded: _resolve_location geocodes over blocking HTTP, so running it
|
||
# inline would stall the event loop before we ever reach the
|
||
# already-offloaded aurora fetch below.
|
||
lat, lon, location_label, err_key = await asyncio.to_thread(
|
||
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
|