From efe074b151274eaaed2c12013fc71ca97dc448ce Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 1 Oct 2026 06:53:14 -0700 Subject: [PATCH] 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. --- modules/command_manager.py | 4 ++ modules/commands/airplanes_command.py | 1 + modules/commands/alert_command.py | 1 + .../commands/alternatives/wx_international.py | 2 +- modules/commands/aqi_command.py | 1 + modules/commands/aurora_command.py | 1 + modules/commands/base_command.py | 9 ++++ modules/commands/catfact_command.py | 1 + modules/commands/channels_command.py | 1 + modules/commands/cmd_command.py | 1 + modules/commands/dice_command.py | 1 + modules/commands/feed_command.py | 1 + modules/commands/hello_command.py | 1 + modules/commands/help_command.py | 1 + modules/commands/hfcond_command.py | 1 + modules/commands/magic8_command.py | 1 + modules/commands/moon_command.py | 1 + modules/commands/multitest_command.py | 1 + modules/commands/path_command.py | 1 + modules/commands/ping_command.py | 1 + modules/commands/prefix_command.py | 1 + modules/commands/rain_command.py | 1 + modules/commands/reload_command.py | 1 + modules/commands/repeater_command.py | 1 + modules/commands/roll_command.py | 1 + modules/commands/satpass_command.py | 1 + modules/commands/solar_command.py | 1 + modules/commands/solarforecast_command.py | 1 + modules/commands/sports_command.py | 1 + modules/commands/sun_command.py | 1 + modules/commands/test_command.py | 1 + modules/commands/trace_command.py | 1 + modules/commands/webviewer_command.py | 1 + modules/commands/worldcup_command.py | 1 + modules/commands/wx_command.py | 6 ++- modules/core.py | 22 +++++----- modules/feed_manager.py | 4 +- modules/location.py | 11 +++-- modules/message_handler.py | 36 ++++++++++++++++ modules/plugin_loader.py | 25 +++++++++++ modules/service_plugins/base_service.py | 4 +- tests/test_core.py | 21 ++++++++++ .../test_base_service_standalone_import.py | 28 +++++++++++++ tests/unit/test_dm_routing_log.py | 41 +++++++++++++++++++ tests/unit/test_enabled_attr.py | 20 +++++++++ tests/unit/test_feed_parse.py | 7 ++++ tests/unit/test_weather_location_logging.py | 23 +++++++++++ 47 files changed, 277 insertions(+), 17 deletions(-) create mode 100644 tests/unit/test_base_service_standalone_import.py create mode 100644 tests/unit/test_dm_routing_log.py create mode 100644 tests/unit/test_weather_location_logging.py diff --git a/modules/command_manager.py b/modules/command_manager.py index 0b179c4..5dec7b5 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -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) + diff --git a/modules/commands/airplanes_command.py b/modules/commands/airplanes_command.py index c6d4826..c06a806 100644 --- a/modules/commands/airplanes_command.py +++ b/modules/commands/airplanes_command.py @@ -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])" diff --git a/modules/commands/alert_command.py b/modules/commands/alert_command.py index 1f826b5..847bd1c 100644 --- a/modules/commands/alert_command.py +++ b/modules/commands/alert_command.py @@ -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)" diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index 96d36e6..0ba1aff 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -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).""" diff --git a/modules/commands/aqi_command.py b/modules/commands/aqi_command.py index fd1d97d..f4a86f4 100644 --- a/modules/commands/aqi_command.py +++ b/modules/commands/aqi_command.py @@ -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)" diff --git a/modules/commands/aurora_command.py b/modules/commands/aurora_command.py index be2f732..1801efc 100644 --- a/modules/commands/aurora_command.py +++ b/modules/commands/aurora_command.py @@ -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" diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index 262c1ca..9b8bd2e 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -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 [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 diff --git a/modules/commands/catfact_command.py b/modules/commands/catfact_command.py index 75caec6..57d07f5 100644 --- a/modules/commands/catfact_command.py +++ b/modules/commands/catfact_command.py @@ -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)" diff --git a/modules/commands/channels_command.py b/modules/commands/channels_command.py index 918b865..b5c3b69 100644 --- a/modules/commands/channels_command.py +++ b/modules/commands/channels_command.py @@ -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 ' for specific categories, 'channels #channel' for specific channel info." diff --git a/modules/commands/cmd_command.py b/modules/commands/cmd_command.py index 6b0f7ad..e8c5758 100644 --- a/modules/commands/cmd_command.py +++ b/modules/commands/cmd_command.py @@ -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" diff --git a/modules/commands/dice_command.py b/modules/commands/dice_command.py index 38e250d..dfc52f1 100644 --- a/modules/commands/dice_command.py +++ b/modules/commands/dice_command.py @@ -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." diff --git a/modules/commands/feed_command.py b/modules/commands/feed_command.py index 15e2ba8..c3b0de7 100644 --- a/modules/commands/feed_command.py +++ b/modules/commands/feed_command.py @@ -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'] diff --git a/modules/commands/hello_command.py b/modules/commands/hello_command.py index c4ed0be..cf3dd48 100644 --- a/modules/commands/hello_command.py +++ b/modules/commands/hello_command.py @@ -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" diff --git a/modules/commands/help_command.py b/modules/commands/help_command.py index 25f64c0..67b1552 100644 --- a/modules/commands/help_command.py +++ b/modules/commands/help_command.py @@ -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 ' for details." diff --git a/modules/commands/hfcond_command.py b/modules/commands/hfcond_command.py index 9e47c44..0e0fb4f 100644 --- a/modules/commands/hfcond_command.py +++ b/modules/commands/hfcond_command.py @@ -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" diff --git a/modules/commands/magic8_command.py b/modules/commands/magic8_command.py index f529461..2c14ed4 100644 --- a/modules/commands/magic8_command.py +++ b/modules/commands/magic8_command.py @@ -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'" diff --git a/modules/commands/moon_command.py b/modules/commands/moon_command.py index 245c2bb..7675261 100644 --- a/modules/commands/moon_command.py +++ b/modules/commands/moon_command.py @@ -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" diff --git a/modules/commands/multitest_command.py b/modules/commands/multitest_command.py index 5a89c0e..beda478 100644 --- a/modules/commands/multitest_command.py +++ b/modules/commands/multitest_command.py @@ -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" diff --git a/modules/commands/path_command.py b/modules/commands/path_command.py index f2d7fa9..28a2398 100644 --- a/modules/commands/path_command.py +++ b/modules/commands/path_command.py @@ -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" diff --git a/modules/commands/ping_command.py b/modules/commands/ping_command.py index b4662e2..26f8910 100644 --- a/modules/commands/ping_command.py +++ b/modules/commands/ping_command.py @@ -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!'" diff --git a/modules/commands/prefix_command.py b/modules/commands/prefix_command.py index a8d4b37..13e0bd4 100644 --- a/modules/commands/prefix_command.py +++ b/modules/commands/prefix_command.py @@ -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)" diff --git a/modules/commands/rain_command.py b/modules/commands/rain_command.py index 784ae0a..33002d5 100644 --- a/modules/commands/rain_command.py +++ b/modules/commands/rain_command.py @@ -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" diff --git a/modules/commands/reload_command.py b/modules/commands/reload_command.py index e21a8f9..be4c555 100644 --- a/modules/commands/reload_command.py +++ b/modules/commands/reload_command.py @@ -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)" diff --git a/modules/commands/repeater_command.py b/modules/commands/repeater_command.py index d0a4181..bce9c0a 100644 --- a/modules/commands/repeater_command.py +++ b/modules/commands/repeater_command.py @@ -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)" diff --git a/modules/commands/roll_command.py b/modules/commands/roll_command.py index ea3a0fa..91fcfbd 100644 --- a/modules/commands/roll_command.py +++ b/modules/commands/roll_command.py @@ -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." diff --git a/modules/commands/satpass_command.py b/modules/commands/satpass_command.py index d0c8d4c..d44ecfa 100644 --- a/modules/commands/satpass_command.py +++ b/modules/commands/satpass_command.py @@ -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 [visual]" diff --git a/modules/commands/solar_command.py b/modules/commands/solar_command.py index 0184645..78f11c9 100644 --- a/modules/commands/solar_command.py +++ b/modules/commands/solar_command.py @@ -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" diff --git a/modules/commands/solarforecast_command.py b/modules/commands/solarforecast_command.py index 038d0b1..ce5cf5f 100644 --- a/modules/commands/solarforecast_command.py +++ b/modules/commands/solarforecast_command.py @@ -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 [panel_size] [azimuth, 0=south] [angle])" diff --git a/modules/commands/sports_command.py b/modules/commands/sports_command.py index c42ffc1..c132afe 100644 --- a/modules/commands/sports_command.py +++ b/modules/commands/sports_command.py @@ -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])" diff --git a/modules/commands/sun_command.py b/modules/commands/sun_command.py index 2aacf8b..75e24ef 100644 --- a/modules/commands/sun_command.py +++ b/modules/commands/sun_command.py @@ -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" diff --git a/modules/commands/test_command.py b/modules/commands/test_command.py index b49f7e4..141cb6c 100644 --- a/modules/commands/test_command.py +++ b/modules/commands/test_command.py @@ -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" diff --git a/modules/commands/trace_command.py b/modules/commands/trace_command.py index c1cdfda..cff8fa1 100644 --- a/modules/commands/trace_command.py +++ b/modules/commands/trace_command.py @@ -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)" diff --git a/modules/commands/webviewer_command.py b/modules/commands/webviewer_command.py index 3d943b1..04be678 100644 --- a/modules/commands/webviewer_command.py +++ b/modules/commands/webviewer_command.py @@ -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)" diff --git a/modules/commands/worldcup_command.py b/modules/commands/worldcup_command.py index 4660d31..bb9e3cf 100644 --- a/modules/commands/worldcup_command.py +++ b/modules/commands/worldcup_command.py @@ -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)" diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index db2412b..a3c6326 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -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") diff --git a/modules/core.py b/modules/core.py index 641ad64..847458b 100644 --- a/modules/core.py +++ b/modules/core.py @@ -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 diff --git a/modules/feed_manager.py b/modules/feed_manager.py index 35ff998..891fc38 100644 --- a/modules/feed_manager.py +++ b/modules/feed_manager.py @@ -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, diff --git a/modules/location.py b/modules/location.py index c7b9789..be2d157 100644 --- a/modules/location.py +++ b/modules/location.py @@ -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 diff --git a/modules/message_handler.py b/modules/message_handler.py index d835675..2fa5d82 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -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 diff --git a/modules/plugin_loader.py b/modules/plugin_loader.py index 01832e3..f8fd16e 100644 --- a/modules/plugin_loader.py +++ b/modules/plugin_loader.py @@ -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 + diff --git a/modules/service_plugins/base_service.py b/modules/service_plugins/base_service.py index 4e5d50b..4f3d743 100644 --- a/modules/service_plugins/base_service.py +++ b/modules/service_plugins/base_service.py @@ -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 diff --git a/tests/test_core.py b/tests/test_core.py index a60bef8..3f710ad 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1188,3 +1188,24 @@ async def _make_coro_async(value): def _make_coro(value): """Return a coroutine that immediately resolves to *value*.""" return _make_coro_async(value) + + +class TestHealthStateAvailableToPluginConstructors: + """Plugins built in MeshCoreBot.__init__ may read radio-health properties.""" + + def test_command_constructor_can_read_health_properties(self, tmp_path): + from modules.command_manager import CommandManager + + seen = {} + real_init = CommandManager.__init__ + + def spying_init(self, bot): + seen["offline"] = bot.is_radio_offline + seen["zombie"] = bot.is_radio_zombie + real_init(self, bot) + + config_file = tmp_path / "config.ini" + _write_config(config_file, tmp_path / "bot.db") + with patch.object(CommandManager, "__init__", spying_init): + MeshCoreBot(config_file=str(config_file)) + assert seen == {"offline": False, "zombie": False} diff --git a/tests/unit/test_base_service_standalone_import.py b/tests/unit/test_base_service_standalone_import.py new file mode 100644 index 0000000..49b2c07 --- /dev/null +++ b/tests/unit/test_base_service_standalone_import.py @@ -0,0 +1,28 @@ +"""base_service.py must import cleanly when loaded as local_services.base_service. + +ServicePluginLoader supports local services that do +``from .base_service import BaseServicePlugin`` by executing this same file +under the ``local_services`` package, where relative parent imports +(``from ..x``) cannot resolve. +""" + +import importlib.util +import sys +import types +from pathlib import Path + + +def test_base_service_imports_under_a_foreign_package_name(): + path = Path(__file__).resolve().parents[2] / "modules" / "service_plugins" / "base_service.py" + pkg = types.ModuleType("local_services_probe") + pkg.__path__ = [] + sys.modules["local_services_probe"] = pkg + try: + spec = importlib.util.spec_from_file_location("local_services_probe.base_service", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + assert hasattr(module, "BaseServicePlugin") + finally: + sys.modules.pop("local_services_probe.base_service", None) + sys.modules.pop("local_services_probe", None) diff --git a/tests/unit/test_dm_routing_log.py b/tests/unit/test_dm_routing_log.py new file mode 100644 index 0000000..9a97daa --- /dev/null +++ b/tests/unit/test_dm_routing_log.py @@ -0,0 +1,41 @@ +"""The INFO route lines for a pubkey-correlated DM are still logged.""" + +from unittest.mock import MagicMock + +from modules.message_handler import RF_MATCH_EXACT, RF_MATCH_FALLBACK, RF_MATCH_KEY, MessageHandler + + +def _handler(rf): + mh = object.__new__(MessageHandler) + mh.logger = MagicMock() + mh.find_recent_rf_data = MagicMock(return_value=rf) + mh.decode_meshcore_packet = MagicMock(return_value={"route_type": 1}) + return mh + + +def _infos(mh): + return [c.args[0] for c in mh.logger.info.call_args_list] + + +def test_routed_dm_logs_message_routing(): + mh = _handler({"raw_hex": "aa", RF_MATCH_KEY: RF_MATCH_EXACT, + "routing_info": {"path_length": 2, "path_nodes": ["01", "02"], "route_type": "FLOOD"}}) + mh._log_dm_routing_from_rf("ab12") + assert _infos(mh) == ["🛣️ MESSAGE ROUTING: 01,02 (2 hops via FLOOD)"] + + +def test_direct_dm_logs_direct_message(): + mh = _handler({"raw_hex": "aa", RF_MATCH_KEY: RF_MATCH_EXACT, + "routing_info": {"path_length": 0, "route_type": "DIRECT"}}) + mh._log_dm_routing_from_rf("ab12") + assert _infos(mh) == ["📡 DIRECT MESSAGE: Direct via DIRECT"] + + +def test_uncorrelated_or_missing_rf_logs_nothing_at_info(): + mh = _handler({"raw_hex": "aa", RF_MATCH_KEY: RF_MATCH_FALLBACK, + "routing_info": {"path_length": 0, "route_type": "DIRECT"}}) + mh._log_dm_routing_from_rf("ab12") + assert _infos(mh) == [] + mh = _handler(None) + mh._log_dm_routing_from_rf("ab12") + assert _infos(mh) == [] diff --git a/tests/unit/test_enabled_attr.py b/tests/unit/test_enabled_attr.py index 08a4f27..11daece 100644 --- a/tests/unit/test_enabled_attr.py +++ b/tests/unit/test_enabled_attr.py @@ -60,3 +60,23 @@ def test_admin_only_requires_admin_without_acl_config(): cmd = _Admin(_bot()) assert cmd.requires_admin_access() is True assert _Switchable(_bot(), enabled=True).requires_admin_access() is False + + +def test_legacy_commands_still_enforce_channels_when_asked_to_skip(): + """Commands whose old override dropped skip_channel_check keep doing so.""" + from modules.commands.ping_command import PingCommand + from modules.commands.version_command import VersionCommand + + assert PingCommand.honors_skip_channel_check is False + assert VersionCommand.honors_skip_channel_check is True + + class _Legacy(_Switchable): + honors_skip_channel_check = False + + cmd = _Legacy(_bot(), enabled=True) + cmd.allowed_channels = ["allowed"] + blocked = MeshMessage(content="switchable", sender_id="Ann", channel="blocked", is_dm=False) + assert cmd.can_execute(blocked, skip_channel_check=True) is False + modern = _Switchable(_bot(), enabled=True) + modern.allowed_channels = ["allowed"] + assert modern.can_execute(blocked, skip_channel_check=True) is True diff --git a/tests/unit/test_feed_parse.py b/tests/unit/test_feed_parse.py index 7edd9d2..0e2d632 100644 --- a/tests/unit/test_feed_parse.py +++ b/tests/unit/test_feed_parse.py @@ -73,3 +73,10 @@ def test_allow_private_falls_back_to_feed_command(): cfg = _config(Feed_Command={"allow_private_urls": "true"}, Feed_Manager={"allow_private_urls": "false"}) assert fp.feed_allow_private_urls(cfg) is False assert fp.feed_allow_private_urls(_config(Feed_Command={"allow_private_urls": "true"}, Feed_Manager={})) is True + + +def test_feed_manager_still_exports_the_size_defaults(): + from modules import feed_manager + + assert feed_manager.DEFAULT_MAX_FEED_RESPONSE_BYTES == fp.DEFAULT_MAX_FEED_RESPONSE_BYTES + assert feed_manager.DEFAULT_MAX_PARSED_FEED_ITEMS == fp.DEFAULT_MAX_PARSED_FEED_ITEMS diff --git a/tests/unit/test_weather_location_logging.py b/tests/unit/test_weather_location_logging.py new file mode 100644 index 0000000..6fc2fef --- /dev/null +++ b/tests/unit/test_weather_location_logging.py @@ -0,0 +1,23 @@ +"""wx and gwx keep logging companion-location lookup failures at WARNING.""" + +from unittest.mock import MagicMock + +import pytest + +from modules.models import MeshMessage + + +@pytest.mark.parametrize("which", ["wx", "gwx"]) +def test_companion_lookup_error_is_a_warning(which): + if which == "wx": + from modules.commands.wx_command import WxCommand as cls + else: + from modules.commands.alternatives.wx_international import GlobalWxCommand as cls + cmd = object.__new__(cls) + cmd.bot = MagicMock() + cmd.bot.db_manager.execute_query.side_effect = RuntimeError("database is locked") + cmd.logger = MagicMock() + msg = MeshMessage(content="wx", sender_id="Ann", sender_pubkey="ab" * 32) + assert cmd._get_companion_location(msg) is None + cmd.logger.warning.assert_called_once() + assert "database is locked" in cmd.logger.warning.call_args[0][0]