From 6a66eab663f61b7ea6692eb2952e5a88a3b49b27 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 22 Feb 2026 11:40:12 -0500 Subject: [PATCH] Refine LetsMesh status ingest and custom logo behavior --- .gitignore | 1 + README.md | 8 +- SCHEMAS.md | 2 + src/meshcore_hub/collector/subscriber.py | 85 +++++++++++++------ src/meshcore_hub/web/app.py | 31 +++++-- src/meshcore_hub/web/static/css/app.css | 4 +- .../web/static/js/spa/pages/home.js | 5 +- src/meshcore_hub/web/templates/spa.html | 8 +- tests/test_collector/test_subscriber.py | 59 ++++++++++++- 9 files changed, 162 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 0330f50..13014d0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ !example/data/ /seed/ !example/seed/ +/content/ # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index 1590a27..f0209df 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ When `COLLECTOR_INGEST_MODE=letsmesh_upload`, the collector subscribes to: Normalization behavior: -- `status` packets are mapped to `advertisement` events. +- `status` packets are mapped to `advertisement` events only when node identity metadata is present (`name`, node type, explicit flags, or location); heartbeat/counter-only status frames are stored as `letsmesh_status` logs. - Decoder payload types `4` and `11` are also mapped to `advertisement` events when node identity metadata is present. - `packet_type=5` packets are mapped to `channel_msg_recv`. - `packet_type=1`, `2`, and `7` packets are mapped to `contact_msg_recv` when decryptable text is available. @@ -321,6 +321,7 @@ Normalization behavior: - In the messages feed and dashboard channel sections, known channel indexes are preferred for labels (`17 -> Public`, `217 -> #test`) to avoid stale channel-name mismatches. - Additional channel names are loaded from `COLLECTOR_LETSMESH_DECODER_KEYS` when entries are provided as `label=hex` (for example `bot=`). - Decoder-advertisement packets with location metadata update node GPS (`lat/lon`) for map display. +- Status `stats.debug_flags` values are not used as advertisement capability flags. - Packets without decryptable message text are kept as informational `letsmesh_packet` events and are not shown in the messages feed; when decode succeeds the decoded JSON is attached to those packet log events. - When decoder output includes a human sender (`payload.decoded.decrypted.sender`), message text is normalized to `Name: Message` before storage; receiver/observer names are never used as sender fallback. - The collector keeps built-in keys for `Public` and `#test`, and merges any additional keys from `COLLECTOR_LETSMESH_DECODER_KEYS`. @@ -425,6 +426,9 @@ Control which pages are visible in the web dashboard. Disabled features are full The web dashboard supports custom content including markdown pages and media files. Content is organized in subdirectories: ``` + +Custom logo note: +- If a custom logo file is present, the UI keeps its original colors in both light/dark themes (no automatic light-mode darkening). content/ ├── pages/ # Custom markdown pages │ └── about.md @@ -704,7 +708,7 @@ meshcore-hub/ ├── content/ # Custom content directory (CONTENT_HOME, optional) │ ├── pages/ # Custom markdown pages │ └── media/ # Custom media files -│ └── images/ # Custom images (logo.svg replaces default logo) +│ └── images/ # Custom images (logo.svg/png/jpg/jpeg/webp replace default logo) ├── data/ # Runtime data directory (DATA_HOME, created at runtime) ├── Dockerfile # Docker build configuration ├── docker-compose.yml # Docker Compose services diff --git a/SCHEMAS.md b/SCHEMAS.md index 616702a..7448593 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -184,8 +184,10 @@ Group/broadcast messages on specific channels. - When decoder output includes a human sender (`payload.decoded.decrypted.sender`), message text is normalized to `Name: Message`; sender identity remains unknown when only hash/prefix metadata is available. **Compatibility ingest note (advertisements)**: +- In LetsMesh upload compatibility mode, `status` feed payloads are normalized to `ADVERTISEMENT` only when identity metadata is present (`name`, node type, explicit `flags`, or location). Status heartbeat/counter frames are persisted as informational `letsmesh_status` events. - In LetsMesh upload compatibility mode, decoded payload types `4` and `11` are normalized to `ADVERTISEMENT` when node identity metadata is present. - Payload type `4` location metadata (`appData.location.latitude/longitude`) is mapped to node `lat/lon` for map rendering. +- `stats.debug_flags` from LetsMesh status feeds are not persisted as advertisement capability flags. --- diff --git a/src/meshcore_hub/collector/subscriber.py b/src/meshcore_hub/collector/subscriber.py index 9b72582..63b3410 100644 --- a/src/meshcore_hub/collector/subscriber.py +++ b/src/meshcore_hub/collector/subscriber.py @@ -185,33 +185,13 @@ class Subscriber: observer_public_key, feed_type = parsed if feed_type == "status": - status_public_key = ( - payload.get("origin_id") - or payload.get("public_key") - or observer_public_key + normalized_status = self._build_letsmesh_status_advertisement_payload( + payload, + observer_public_key=observer_public_key, ) - normalized_payload = dict(payload) - normalized_payload["public_key"] = status_public_key - - status_name = payload.get("origin") or payload.get("name") - if status_name and not normalized_payload.get("name"): - normalized_payload["name"] = status_name - - normalized_adv_type = self._normalize_letsmesh_adv_type(normalized_payload) - if normalized_adv_type: - normalized_payload["adv_type"] = normalized_adv_type - else: - normalized_payload.pop("adv_type", None) - - stats = payload.get("stats") - if ( - isinstance(stats, dict) - and "flags" not in normalized_payload - and "debug_flags" in stats - ): - normalized_payload["flags"] = stats["debug_flags"] - - return observer_public_key, "advertisement", normalized_payload + if normalized_status: + return observer_public_key, "advertisement", normalized_status + return observer_public_key, "letsmesh_status", dict(payload) if feed_type == "packets": decoded_packet = self._letsmesh_decoder.decode_payload(payload) @@ -445,6 +425,59 @@ class Subscriber: return normalized_payload + def _build_letsmesh_status_advertisement_payload( + self, + payload: dict[str, Any], + observer_public_key: str, + ) -> dict[str, Any] | None: + """Normalize LetsMesh status feed payloads into advertisement events.""" + status_public_key = self._normalize_full_public_key( + payload.get("origin_id") or payload.get("public_key") or observer_public_key + ) + if not status_public_key: + return None + + normalized_payload: dict[str, Any] = {"public_key": status_public_key} + + status_name = payload.get("origin") or payload.get("name") + if isinstance(status_name, str) and status_name.strip(): + normalized_payload["name"] = status_name.strip() + + normalized_adv_type = self._normalize_letsmesh_adv_type(payload) + if normalized_adv_type: + normalized_payload["adv_type"] = normalized_adv_type + + # Only trust explicit status payload flags. stats.debug_flags are observer/debug + # counters and cause false capability flags + inflated dedup churn. + explicit_flags = self._parse_int(payload.get("flags")) + if explicit_flags is not None: + normalized_payload["flags"] = explicit_flags + + lat = self._parse_float(payload.get("lat")) + lon = self._parse_float(payload.get("lon")) + if lat is None: + lat = self._parse_float(payload.get("adv_lat")) + if lon is None: + lon = self._parse_float(payload.get("adv_lon")) + location = payload.get("location") + if isinstance(location, dict): + if lat is None: + lat = self._parse_float(location.get("latitude")) + if lon is None: + lon = self._parse_float(location.get("longitude")) + if lat is not None: + normalized_payload["lat"] = lat + if lon is not None: + normalized_payload["lon"] = lon + + # Ignore status heartbeat/counter frames that have no node identity metadata. + if not any( + key in normalized_payload + for key in ("name", "adv_type", "flags", "lat", "lon") + ): + return None + return normalized_payload + @classmethod def _extract_letsmesh_text( cls, diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 0dd638f..6316d91 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -50,6 +50,24 @@ def _build_channel_labels() -> dict[str, str]: return {str(idx): label for idx, label in sorted(labels.items())} +def _resolve_logo(media_home: Path) -> tuple[str, bool, Path | None]: + """Resolve logo URL and whether light-mode inversion should be applied. + + Returns: + tuple of (logo_url, invert_in_light_mode, resolved_path) + """ + custom_logo_candidates = (("logo.svg", "/media/images/logo.svg"),) + for filename, url in custom_logo_candidates: + path = media_home / "images" / filename + if path.exists(): + # Custom logos are assumed to be full-color and should not be darkened. + cache_buster = int(path.stat().st_mtime) + return f"{url}?v={cache_buster}", False, path + + # Default packaged logo is monochrome and needs darkening in light mode. + return "/static/img/logo.svg", True, None + + def _is_authenticated_proxy_request(request: Request) -> bool: """Check whether request is authenticated by an upstream auth proxy. @@ -157,6 +175,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str: "datetime_locale": app.state.web_datetime_locale, "auto_refresh_seconds": app.state.auto_refresh_seconds, "channel_labels": app.state.channel_labels, + "logo_invert_light": app.state.logo_invert_light, } return json.dumps(config) @@ -300,12 +319,11 @@ def create_app( # Check for custom logo and store media path media_home = Path(settings.effective_media_home) - custom_logo_path = media_home / "images" / "logo.svg" - if custom_logo_path.exists(): - app.state.logo_url = "/media/images/logo.svg" - logger.info(f"Using custom logo from {custom_logo_path}") - else: - app.state.logo_url = "/static/img/logo.svg" + logo_url, logo_invert_light, logo_path = _resolve_logo(media_home) + app.state.logo_url = logo_url + app.state.logo_invert_light = logo_invert_light + if logo_path is not None: + logger.info("Using custom logo from %s", logo_path) # Mount static files if STATIC_DIR.exists(): @@ -697,6 +715,7 @@ def create_app( "features": features, "custom_pages": custom_pages, "logo_url": request.app.state.logo_url, + "logo_invert_light": request.app.state.logo_invert_light, "version": __version__, "default_theme": request.app.state.web_theme, "config_json": config_json, diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css index bfcb72f..13e2114 100644 --- a/src/meshcore_hub/web/static/css/app.css +++ b/src/meshcore_hub/web/static/css/app.css @@ -46,8 +46,8 @@ /* Spacing between horizontal nav items */ .menu-horizontal { gap: 0.125rem; } -/* Invert white logos/images to dark for light mode */ -[data-theme="light"] .theme-logo { +/* Invert monochrome logos to dark for light mode */ +[data-theme="light"] .theme-logo--invert-light { filter: brightness(0.15); } diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js index 9a1a2da..eb1d9b1 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ b/src/meshcore_hub/web/static/js/spa/pages/home.js @@ -33,6 +33,7 @@ export async function render(container, params, router) { const features = config.features || {}; const networkName = config.network_name || 'MeshCore Network'; const logoUrl = config.logo_url || '/static/img/logo.svg'; + const logoInvertLight = config.logo_invert_light !== false; const customPages = config.custom_pages || []; const rc = config.network_radio_config; @@ -69,7 +70,7 @@ export async function render(container, params, router) {
- +

${networkName}

${cityCountry} @@ -158,7 +159,7 @@ export async function render(container, params, router) {

${t('home.meshcore_attribution')}

- +

Connecting people and things, without using the internet

diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index f660c1f..1188916 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -30,6 +30,12 @@ + {% if not logo_invert_light %} + + {% endif %} @@ -87,7 +93,7 @@
- + {{ network_name }} {{ network_name }}
diff --git a/tests/test_collector/test_subscriber.py b/tests/test_collector/test_subscriber.py index e4ee3de..adc29ae 100644 --- a/tests/test_collector/test_subscriber.py +++ b/tests/test_collector/test_subscriber.py @@ -110,7 +110,7 @@ class TestSubscriber: "origin_id": "b" * 64, "model": "Heltec V3", "mode": "repeater", - "stats": {"debug_flags": 7}, + "flags": 7, }, ) @@ -118,11 +118,66 @@ class TestSubscriber: public_key, event_type, payload, _db = handler.call_args.args assert public_key == "a" * 64 assert event_type == "advertisement" - assert payload["public_key"] == "b" * 64 + assert payload["public_key"] == ("b" * 64).upper() assert payload["name"] == "Observer Node" assert payload["adv_type"] == "repeater" assert payload["flags"] == 7 + def test_letsmesh_status_does_not_use_debug_flags_as_advert_flags( + self, mock_mqtt_client, db_manager + ) -> None: + """debug_flags should not be stored as node capability flags.""" + subscriber = Subscriber( + mock_mqtt_client, + db_manager, + ingest_mode="letsmesh_upload", + ) + handler = MagicMock() + subscriber.register_handler("advertisement", handler) + subscriber.start() + + subscriber._handle_mqtt_message( + topic=f"meshcore/BOS/{'a' * 64}/status", + pattern="meshcore/BOS/+/status", + payload={ + "origin": "Observer Node", + "origin_id": "b" * 64, + "mode": "repeater", + "stats": {"debug_flags": 7}, + }, + ) + + handler.assert_called_once() + _public_key, _event_type, payload, _db = handler.call_args.args + assert "flags" not in payload + + def test_letsmesh_status_without_identity_maps_to_letsmesh_status( + self, mock_mqtt_client, db_manager + ) -> None: + """Status heartbeat payloads without identity metadata should not inflate adverts.""" + subscriber = Subscriber( + mock_mqtt_client, + db_manager, + ingest_mode="letsmesh_upload", + ) + advert_handler = MagicMock() + status_handler = MagicMock() + subscriber.register_handler("advertisement", advert_handler) + subscriber.register_handler("letsmesh_status", status_handler) + subscriber.start() + + subscriber._handle_mqtt_message( + topic=f"meshcore/BOS/{'a' * 64}/status", + pattern="meshcore/BOS/+/status", + payload={ + "origin_id": "b" * 64, + "stats": {"cpu": 27, "mem": 91, "debug_flags": 7}, + }, + ) + + advert_handler.assert_not_called() + status_handler.assert_called_once() + def test_invalid_ingest_mode_raises(self, mock_mqtt_client, db_manager) -> None: """Invalid ingest mode values are rejected.""" with pytest.raises(ValueError):