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()