From 73e7b9514043e67b2ef3bd181c9669f8eb007128 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 17 Oct 2026 18:37:46 -0700 Subject: [PATCH] fix(core): replace a stale radio-offline flag at startup bot.radio_offline is written only when the breaker changes state. A run that went offline and was then restarted left 'true' in bot_metadata, and the new process, which starts with no send failures, never transitioned, so nothing overwrote it: the web viewer showed Radio Offline indefinitely while the bot sent normally. The viewer-clear check couldn't help either, since it only runs while the bot itself thinks it's offline. start() now publishes the fresh offline state right after setting bot.initializing, before the early web viewer starts, so the stored flag always belongs to the current run. The zombie flag didn't need this; a successful connect already writes it false. --- CHANGELOG.md | 1 + modules/core.py | 6 ++++++ tests/test_core.py | 26 ++++++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aacc908..5ba6f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ semantic versioning. ### Fixed +- The web viewer no longer shows Radio Offline for good after a restart. The bot wrote the offline flag only when it changed, so a `true` left by an earlier run stayed in the database while the new run sent normally, and the banner never cleared. The bot now records its own state at startup, before the viewer starts. - Web viewer keyboard and screen-reader access: contact columns sort from the keyboard and announce their sort order, plugin cards and Realtime packet details open with Enter or Space, and every button and form field on every page has an accessible name (about 300 Plugins settings fields were announced without one). - Web viewer text contrast: status colors used as text (amber, cyan, green, red), muted text, outline buttons, code, log levels and badges now pass WCAG AA in both themes. The worst cases were the dashboard's Gone Quiet count and Contacts' 7-day counts at 1.6:1 and 2.0:1. - Web viewer text no longer drops below 0.7rem (Realtime timestamps were 9.3px), monospace text uses one font stack, and `/config` no longer scrolls sideways on a phone. diff --git a/modules/core.py b/modules/core.py index e0669a0..914b6b9 100644 --- a/modules/core.py +++ b/modules/core.py @@ -891,6 +891,12 @@ class MeshCoreBot(ServiceSupervisorMixin, RadioLinkMixin, RadioOfflineBreaker, D except Exception as e: self.logger.debug("Could not set bot.initializing metadata: %s", e) + # The offline flag in bot_metadata is only written on transitions, so a + # 'true' left by an earlier run would keep the viewer's Radio Offline + # banner up forever: this process starts with no send failures and never + # transitions. Publish the fresh state so the stored flag is this run's. + self._publish_offline_state() + # Start web viewer early (before radio connect) so operators can see the # initializing banner and monitor startup progress if self.web_viewer_integration and self.web_viewer_integration.enabled: diff --git a/tests/test_core.py b/tests/test_core.py index 701735a..e731622 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -928,6 +928,32 @@ class TestRadioOfflineState: bot = self._make_bot(tmp_path) assert bot.is_radio_offline is False + def test_start_replaces_a_stale_offline_flag_from_an_earlier_run(self, tmp_path): + bot = self._make_bot(tmp_path) + bot.db_manager.set_metadata('bot.radio_offline', 'true') + bot.db_manager.set_metadata('bot.radio_offline_since', '2026-10-15T14:42:58+00:00') + + with patch.object(bot, "connect", return_value=False): + asyncio.run(bot.start()) + + assert bot.db_manager.get_metadata('bot.radio_offline') == 'false' + assert bot.db_manager.get_metadata('bot.radio_offline_since') == '' + + def test_start_publishes_before_the_web_viewer_starts(self, tmp_path): + """The early viewer must not render the stale banner from the first request.""" + bot = self._make_bot(tmp_path) + bot.db_manager.set_metadata('bot.radio_offline', 'true') + seen = [] + viewer = MagicMock(enabled=True) + viewer.start_viewer.side_effect = lambda: seen.append( + bot.db_manager.get_metadata('bot.radio_offline')) + bot.web_viewer_integration = viewer + + with patch.object(bot, "connect", return_value=False): + asyncio.run(bot.start()) + + assert seen == ['false'] + def test_record_send_failure_increments_counter(self, tmp_path): bot = self._make_bot(tmp_path) bot._record_send_failure()