refactor: restore behavior the branch had changed (codex review)

A codex review of the branch found seven places where earlier refactor commits changed behavior. Each is restored here with a test:

- base_service imported flood_scope relatively; the local-service loader also runs that file as local_services.base_service, where the import cannot resolve. Now absolute.
- Radio-health fields were initialized after commands and services were built, so a plugin constructor reading is_radio_offline raised. They are now set before any plugin.
- Removing the per-command can_execute overrides made 31 commands honor skip_channel_check, which their overrides had dropped. BaseCommand.honors_skip_channel_check (False on exactly those commands) keeps them enforcing their channel list.
- CommandManager.get_plugin_metadata, WxCommand.get_weather_for_zipcode and PluginLoader.validate_plugin were public compatibility entry points; they are back.
- feed_manager stopped exporting DEFAULT_MAX_FEED_RESPONSE_BYTES and DEFAULT_MAX_PARSED_FEED_ITEMS despite the commit saying it re-exported them; it does now.
- wx and gwx logged companion-location lookup failures at WARNING; the shared helper logged at debug. It now takes the level.
- The INFO 'MESSAGE ROUTING' / 'DIRECT MESSAGE' lines for pubkey-correlated DMs were removed with the dead path_info computation; they are logged again by a log-only helper.
This commit is contained in:
agessaman committed 2026-10-01 06:53:14 -07:00
1 parent adedfa6c9e
commit efe074b151
47 files changed
+277 -17

No files matched your search

+4
View File
@@ -2384,3 +2384,7 @@ class CommandManager:
"""Reload a specific plugin"""
return self.plugin_loader.reload_plugin(plugin_name)
def get_plugin_metadata(self, plugin_name: str | None = None) -> dict[str, Any]:
"""Get plugin metadata"""
return self.plugin_loader.get_plugin_metadata(plugin_name)
+1
View File
@@ -55,6 +55,7 @@ class AirplanesCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "airplanes"
honors_skip_channel_check = False
enabled_attr = "airplanes_enabled"
keywords = ['airplanes', 'aircraft', 'planes', 'adsb', 'overhead']
description = "Get aircraft overhead (usage: airplanes [location] [options] or overhead [lat,lon])"
+1
View File
@@ -176,6 +176,7 @@ class AlertCommand(BaseCommand):
# Plugin metadata
name = "alert"
honors_skip_channel_check = False
enabled_attr = "alert_enabled"
keywords = ['alert', 'alerts', 'incident', 'incidents']
description = "Get active emergency incidents (usage: alert seattle, alert 98258, alert 178th seattle, alert seattle all)"
@@ -141,7 +141,7 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand):
def _get_companion_location(self, message: MeshMessage) -> Optional[tuple[float, float]]:
"""Get companion/sender location from the contact-tracking database."""
return get_companion_lat_lon(self.bot, message, self.logger)
return get_companion_lat_lon(self.bot, message, self.logger, error_level="warning")
def _get_bot_location(self) -> Optional[tuple[float, float]]:
"""Get bot location from config ([Bot] bot_latitude, bot_longitude)."""
+1
View File
@@ -42,6 +42,7 @@ class AqiCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "aqi"
honors_skip_channel_check = False
enabled_attr = "aqi_enabled"
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)"
+1
View File
@@ -23,6 +23,7 @@ class AuroraCommand(BaseCommand):
render_safe = True
name = "aurora"
honors_skip_channel_check = False
enabled_attr = "aurora_enabled"
keywords = ["aurora", "kp"]
description = "Get aurora forecast (KP index and probability) for a location"
+9
View File
@@ -85,6 +85,12 @@ class BaseCommand(ABC):
# lists. requires_admin_access() is still the method callers ask.
admin_only: bool = False
# Whether can_execute honors skip_channel_check. Commands whose own override
# used to drop the argument (it called super().can_execute(message)) set this
# to False so they keep enforcing their channel list even when a delegating
# caller asks to skip it. Fixing that is a behavior change, kept separate.
honors_skip_channel_check: bool = True
# Documentation fields - to be overridden by subclasses for website generation
short_description: str = "" # Brief description for website (without usage syntax)
usage: str = "" # Usage syntax, e.g., "wx <zipcode|city> [tomorrow|7d|hourly|alerts]"
@@ -616,6 +622,9 @@ class BaseCommand(ABC):
if self.enabled_attr is not None and not getattr(self, self.enabled_attr):
return False
if not self.honors_skip_channel_check:
skip_channel_check = False
# Check channel access (standardized channel override)
if not skip_channel_check and not self.is_channel_allowed(message):
return False
+1
View File
@@ -21,6 +21,7 @@ class CatfactCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "catfact"
honors_skip_channel_check = False
enabled_attr = "catfact_enabled"
keywords = ['catfact', 'cat', 'meow', 'purr', 'kitten']
description = "Get a random cat fact (hidden command)"
+1
View File
@@ -21,6 +21,7 @@ class ChannelsCommand(BaseCommand):
# Plugin metadata
name = "channels"
honors_skip_channel_check = False
enabled_attr = "channels_enabled"
keywords = ['channels', 'channel']
description = "Lists hashtag channels with sub-categories. Use 'channels' for general, 'channels list' for all categories, 'channels <category>' for specific categories, 'channels #channel' for specific channel info."
+1
View File
@@ -15,6 +15,7 @@ class CmdCommand(BaseCommand):
# Plugin metadata
name = "cmd"
honors_skip_channel_check = False
enabled_attr = "cmd_enabled"
keywords = ['cmd', 'cmds', 'command', 'commands']
description = "Lists available commands in compact format"
+1
View File
@@ -17,6 +17,7 @@ class DiceCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "dice"
honors_skip_channel_check = False
enabled_attr = "dice_enabled"
keywords = ['dice']
description = "Roll dice for D&D and tabletop games. Use 'dice' for d6, 'dice d20' for d20, 'dice 2d6' for 2d6, 'dice d10 d6' for mixed dice, 'dice decade' for decade die (00-90), etc."
+1
View File
@@ -17,6 +17,7 @@ class FeedCommand(BaseCommand):
# Plugin metadata
name = "feed"
honors_skip_channel_check = False
admin_only = True
enabled_attr = "feed_enabled"
keywords = ['feed', 'feeds', 'rss', 'subscription', 'subscriptions']
+1
View File
@@ -20,6 +20,7 @@ class HelloCommand(BaseCommand):
# Plugin metadata
name = "hello"
honors_skip_channel_check = False
enabled_attr = "hello_enabled"
keywords = ['hello', 'hi', 'hey', 'howdy', 'greetings', 'salutations', 'good morning', 'good afternoon', 'good evening', 'good night', 'yo', 'sup', 'whats up', 'what\'s up', 'morning', 'afternoon', 'evening', 'night', 'gday', 'g\'day', 'hola', 'bonjour', 'ciao', 'namaste', 'aloha', 'shalom', 'konnichiwa', 'guten tag', 'buenos dias', 'buenas tardes', 'buenas noches']
description = "Responds to greetings with robot-themed responses"
+1
View File
@@ -21,6 +21,7 @@ class HelpCommand(BaseCommand):
# Plugin metadata
name = "help"
honors_skip_channel_check = False
enabled_attr = "help_enabled"
keywords = ['help']
description = "Shows commands. Use 'help <command>' for details."
+1
View File
@@ -19,6 +19,7 @@ class HfcondCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "hfcond"
honors_skip_channel_check = False
enabled_attr = "hfcond_enabled"
keywords = ['hfcond']
description = "Get HF band conditions for ham radio"
+1
View File
@@ -26,6 +26,7 @@ class Magic8Command(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "magic8"
honors_skip_channel_check = False
enabled_attr = "magic8_enabled"
keywords = ['magic8']
description = "Emulates the classic Magic 8-ball toy'"
+1
View File
@@ -15,6 +15,7 @@ class MoonCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "moon"
honors_skip_channel_check = False
enabled_attr = "moon_enabled"
keywords = ['moon']
description = "Get moon phase, rise/set times and position"
+1
View File
@@ -462,6 +462,7 @@ class MultitestCommand(BaseCommand):
# Plugin metadata
name = "multitest"
honors_skip_channel_check = False
enabled_attr = "multitest_enabled"
keywords = ['multitest', 'mt']
description = "Listens for 6 seconds and collects all unique paths from incoming messages"
+1
View File
@@ -29,6 +29,7 @@ class PathCommand(BaseCommand):
# Plugin metadata
name = "path"
honors_skip_channel_check = False
enabled_attr = "path_enabled"
keywords = ["path", "decode", "route"]
description = "Decode hex path data to show which repeaters were involved in message routing"
+1
View File
@@ -19,6 +19,7 @@ class PingCommand(BaseCommand):
# Plugin metadata
name = "ping"
honors_skip_channel_check = False
enabled_attr = "ping_enabled"
keywords = ['ping']
description = "Responds to 'ping' with 'Pong!'"
+1
View File
@@ -26,6 +26,7 @@ class PrefixCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "prefix"
honors_skip_channel_check = False
enabled_attr = "prefix_enabled"
keywords = ['prefix', 'lookup']
description = "Look up repeaters by prefix (2, 4, or 6 hex chars = 1–3 bytes; longer input truncated)"
+1
View File
@@ -727,6 +727,7 @@ class RainCommand(BaseCommand):
render_safe = True
name = "rain"
honors_skip_channel_check = False
enabled_attr = "rain_enabled"
keywords = ["rain", "nowcast", "snow"]
description = "Rain/snow nowcast: when precip starts or stops in the next ~2h, with amount"
+1
View File
@@ -13,6 +13,7 @@ class ReloadCommand(BaseCommand):
# Plugin metadata
name = "reload"
honors_skip_channel_check = False
admin_only = True
keywords = ["reload", "reloadconfig", "configreload"]
description = "Reload bot configuration without restart (DM only, admin only)"
+1
View File
@@ -19,6 +19,7 @@ class RepeaterCommand(BaseCommand):
# Plugin metadata
name = "repeater"
honors_skip_channel_check = False
enabled_attr = "repeater_enabled"
keywords = ["repeater", "repeaters", "rp"]
description = "Manage repeater contacts and purging operations (DM only)"
+1
View File
@@ -22,6 +22,7 @@ class RollCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "roll"
honors_skip_channel_check = False
enabled_attr = "roll_enabled"
keywords = ['roll']
description = "Roll a random number between 1 and X (default 100). Use 'roll' for 1-100, 'roll 50' for 1-50, etc."
+1
View File
@@ -15,6 +15,7 @@ class SatpassCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "satpass"
honors_skip_channel_check = False
enabled_attr = "satpass_enabled"
keywords = ['satpass']
description = "Get satellite pass info: satpass <NORAD_number_or_shortcut> [visual]"
+1
View File
@@ -19,6 +19,7 @@ class SolarCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "solar"
honors_skip_channel_check = False
enabled_attr = "solar_enabled"
keywords = ['solar']
description = "Get current solar conditions and HF band info"
@@ -31,6 +31,7 @@ class SolarforecastCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "solarforecast"
honors_skip_channel_check = False
enabled_attr = "solarforecast_enabled"
keywords = ['solarforecast', 'sf']
description = "Get solar panel production forecast (usage: sf <location|repeater_name|coordinates|zipcode> [panel_size] [azimuth, 0=south] [angle])"
+1
View File
@@ -40,6 +40,7 @@ class SportsCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "sports"
honors_skip_channel_check = False
enabled_attr = "sports_enabled"
keywords = ['sports', 'score', 'scores']
description = "Get sports scores and schedules (usage: sports [team/league])"
+1
View File
@@ -19,6 +19,7 @@ class SunCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "sun"
honors_skip_channel_check = False
enabled_attr = "sun_enabled"
keywords = ['sun']
description = "Get sunrise/sunset times"
+1
View File
@@ -28,6 +28,7 @@ class TestCommand(BaseCommand):
# Plugin metadata
name = "test"
honors_skip_channel_check = False
enabled_attr = "test_enabled"
keywords = ['test', 't']
description = "Responds to 'test' or 't' with connection info"
+1
View File
@@ -18,6 +18,7 @@ class TraceCommand(BaseCommand):
"""Trace (manual path) and Tracer (reciprocal path) for link diagnostics."""
name = "trace"
honors_skip_channel_check = False
enabled_attr = "trace_enabled"
keywords = ["trace", "tracer"]
description = "Run a trace along a path (trace=manual if path given, else round-trip; tracer=always round-trip)"
+1
View File
@@ -13,6 +13,7 @@ class WebViewerCommand(BaseCommand):
# Plugin metadata
name = "webviewer"
honors_skip_channel_check = False
enabled_attr = "webviewer_enabled"
keywords = ["webviewer", "web", "viewer", "wv"]
description = "Manage web viewer integration (DM only)"
+1
View File
@@ -38,6 +38,7 @@ class WorldCupCommand(BaseCommand):
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "worldcup"
honors_skip_channel_check = False
enabled_attr = "worldcup_enabled"
keywords = ["wc", "worldcup"]
description = "FIFA World Cup scores, standings, and nation results (in-season only)"
+5 -1
View File
@@ -226,7 +226,7 @@ class WxCommand(WeatherCommandMixin, BaseCommand):
def _get_companion_location(self, message: MeshMessage) -> Optional[tuple[float, float]]:
"""Get companion/sender location from the contact-tracking database."""
return get_companion_lat_lon(self.bot, message, self.logger)
return get_companion_lat_lon(self.bot, message, self.logger, error_level="warning")
def _get_bot_location(self) -> Optional[tuple[float, float]]:
"""Get bot location from config ([Bot] bot_latitude, bot_longitude)."""
@@ -3143,3 +3143,7 @@ class WxCommand(WeatherCommandMixin, BaseCommand):
line = line.replace(key, value).replace(key.capitalize(), value).replace(key.upper(), value)
return line
async def get_weather_for_zipcode(self, zipcode: str) -> str:
"""Get weather data for a specific zipcode (legacy method)"""
return await self.get_weather_for_location(zipcode, "zipcode")
+12 -10
View File
@@ -205,6 +205,18 @@ class MeshCoreBot:
self.channel_rate_limiter = self._load_channel_rate_limiter()
self.tx_delay_ms = self.config.getint('Bot', 'tx_delay_ms', fallback=250)
# Radio health, set before any command or service plugin is built, since
# their constructors may read is_radio_offline / is_radio_zombie. Zombie: the firmware stopped acting on commands and only a
# power cycle recovers it. Offline: repeated send timeouts. The probe
# timestamp starts on the first health-loop pass, hence None until then.
self._radio_zombie_detected = False
self._radio_fail_count = 0
self._tcp_probe_fail_count = 0
self._radio_offline = False
self._send_consecutive_failures = 0
self._last_radio_probe: float | None = None
self._last_health_update = 0.0
# Initialize translator for localization BEFORE CommandManager
# This ensures translated keywords are available when commands are loaded
try:
@@ -363,16 +375,6 @@ class MeshCoreBot:
# Web-viewer reboot/reconnect ops in flight (a count, since they can overlap)
self._radio_relinks_in_progress = 0
# Radio health. Zombie: the firmware stopped acting on commands and only a
# power cycle recovers it. Offline: repeated send timeouts. The probe
# timestamp starts on the first health-loop pass, hence None until then.
self._radio_zombie_detected = False
self._radio_fail_count = 0
self._tcp_probe_fail_count = 0
self._radio_offline = False
self._send_consecutive_failures = 0
self._last_radio_probe: float | None = None
self._last_health_update = 0.0
# Serialize host->radio commands: one companion frame in flight at a
# time, with a minimum inter-command gap so the firmware's single
+3 -1
View File
@@ -32,7 +32,9 @@ from modules.feed_format import (
sort_feed_items,
truncate_to_budget,
)
from modules.feed_parse import (
from modules.feed_parse import ( # noqa: F401 - the DEFAULT_* names are re-exported
DEFAULT_MAX_FEED_RESPONSE_BYTES,
DEFAULT_MAX_PARSED_FEED_ITEMS,
api_item_fields,
feed_allow_private_urls,
feed_max_parsed_items,
+8 -3
View File
@@ -628,8 +628,13 @@ def get_config_default_lat_lon(bot: Any, section: str) -> Optional[tuple[float,
return None
def get_companion_lat_lon(bot: Any, message: Any, logger: Any = None) -> Optional[tuple[float, float]]:
"""The sender's most recent advertised position from contact tracking, or None."""
def get_companion_lat_lon(
bot: Any, message: Any, logger: Any = None, error_level: str = "debug"
) -> Optional[tuple[float, float]]:
"""The sender's most recent advertised position from contact tracking, or None.
A lookup error is logged on ``logger`` at ``error_level`` and returns None.
"""
try:
sender_pubkey = getattr(message, "sender_pubkey", None)
if not sender_pubkey or not hasattr(bot, "db_manager"):
@@ -649,7 +654,7 @@ def get_companion_lat_lon(bot: Any, message: Any, logger: Any = None) -> Optiona
return (float(row["latitude"]), float(row["longitude"]))
except Exception as e:
if logger is not None:
logger.debug(f"Error getting companion location: {e}")
getattr(logger, error_level)(f"Error getting companion location: {e}")
return None
+36
View File
@@ -404,6 +404,40 @@ class MessageHandler:
integration = getattr(self.bot, "web_viewer_integration", None)
return integration.bot_integration if integration else None
def _log_dm_routing_from_rf(self, message_pubkey: str) -> None:
"""Log the route of a DM's correlated RF packet (INFO), when one is found.
Only for DMs without raw_hex, correlated by sender pubkey. This is
logging only: the route actually attached to the message is decided
later in handle_contact_message.
"""
recent_rf_data = self.find_recent_rf_data(message_pubkey)
if not (recent_rf_data and recent_rf_data.get("raw_hex")):
return
decoded_packet = self.decode_meshcore_packet(recent_rf_data["raw_hex"], recent_rf_data.get("payload"))
if not decoded_packet:
return
self.logger.debug(f"Decoded packet for routing from RF data: {decoded_packet}")
if not recent_rf_data.get("routing_info"):
return
if not rf_data_is_correlated(recent_rf_data):
self.logger.debug("Ignoring routing info from an uncorrelated fallback packet")
return
routing_info = recent_rf_data["routing_info"]
self.logger.debug(f"Found routing info: {routing_info}")
path_len = routing_info.get("path_length", 0)
if path_len > 0:
path_hex = routing_info.get("path_hex", "")
path_nodes = routing_info.get("path_nodes", [])
route_type = routing_info.get("route_type", "Unknown")
if path_nodes:
path_info = f"{','.join(path_nodes)} ({path_len} hops via {route_type})"
else:
path_info = f"Path: {path_hex} ({path_len} hops via {route_type})"
self.logger.info(f"🛣️ MESSAGE ROUTING: {path_info}")
else:
self.logger.info(f"📡 DIRECT MESSAGE: Direct via {routing_info.get('route_type', 'Unknown')}")
async def handle_contact_message(self, event: Any, metadata: dict[str, Any] | None = None) -> None:
"""Handle incoming contact message (DM).
@@ -436,6 +470,8 @@ class MessageHandler:
message_raw_hex = payload.get("raw_hex", "")
message_packet_prefix = message_raw_hex[:32] if message_raw_hex else None
message_pubkey = payload.get("pubkey_prefix", "") # Keep for contact lookup
if not message_packet_prefix and message_pubkey:
self._log_dm_routing_from_rf(message_pubkey)
# Get additional metadata - try multiple sources for SNR and RSSI
snr: float | None = None
+25
View File
@@ -539,3 +539,28 @@ class PluginLoader:
self.logger.error(f"Error reloading plugin {plugin_name}: {e}")
return False
def validate_plugin(self, plugin_instance: BaseCommand) -> list[str]:
"""Validate a plugin instance and return any issues"""
issues = []
metadata = plugin_instance.get_metadata()
# Check required metadata
if not metadata.get('name'):
issues.append("Plugin missing 'name' metadata")
if not metadata.get('description'):
issues.append("Plugin missing 'description' metadata")
# Check if execute method is implemented
if not hasattr(plugin_instance, 'execute'):
issues.append("Plugin missing 'execute' method")
# Check for keyword conflicts
for keyword in metadata.get('keywords', []):
if keyword.lower() in self.keyword_mappings:
existing_plugin = self.keyword_mappings[keyword.lower()]
if existing_plugin != metadata['name']:
issues.append(f"Keyword '{keyword}' conflicts with plugin '{existing_plugin}'")
return issues
+3 -1
View File
@@ -10,7 +10,9 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Optional
from ..flood_scope import section_flood_scope
# Absolute on purpose: the local-service loader also executes this file as
# local_services.base_service, where a relative parent import cannot resolve.
from modules.flood_scope import section_flood_scope
@dataclass