Refine LetsMesh status ingest and custom logo behavior

This commit is contained in:
yellowcooln
2026-02-22 11:40:12 -05:00
parent 2f40b4a730
commit 6a66eab663
9 changed files with 162 additions and 41 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
!example/data/
/seed/
!example/seed/
/content/
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@@ -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=<key>`).
- 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

View File

@@ -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.
---

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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) {
<div class="${showStats ? 'grid grid-cols-1 lg:grid-cols-3 gap-6' : ''} bg-base-100 rounded-box shadow-xl p-6">
<div class="${showStats ? 'lg:col-span-2' : ''} flex flex-col items-center text-center">
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 mb-4">
<img src="${logoUrl}" alt="${networkName}" class="theme-logo h-24 w-24 sm:h-36 sm:w-36" />
<img src="${logoUrl}" alt="${networkName}" class="theme-logo ${logoInvertLight ? 'theme-logo--invert-light' : ''} h-24 w-24 sm:h-36 sm:w-36" />
<div class="flex flex-col justify-center">
<h1 class="hero-title text-3xl sm:text-5xl lg:text-6xl font-black tracking-tight">${networkName}</h1>
${cityCountry}
@@ -158,7 +159,7 @@ export async function render(container, params, router) {
<div class="card-body flex flex-col items-center justify-center">
<p class="text-sm opacity-70 mb-4 text-center">${t('home.meshcore_attribution')}</p>
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity">
<img src="/static/img/meshcore.svg" alt="MeshCore" class="theme-logo h-8" />
<img src="/static/img/meshcore.svg" alt="MeshCore" class="theme-logo theme-logo--invert-light h-8" />
</a>
<p class="text-xs opacity-50 mt-4 text-center">Connecting people and things, without using the internet</p>
<div class="flex gap-2 mt-4">

View File

@@ -30,6 +30,12 @@
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="{{ logo_url }}">
{% if not logo_invert_light %}
<style>
/* Keep custom network logos full-color in light mode */
[data-theme="light"] img[src="{{ logo_url }}"] { filter: none !important; }
</style>
{% endif %}
<!-- Tailwind CSS with DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
@@ -87,7 +93,7 @@
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
<img src="{{ logo_url }}" alt="{{ network_name }}" class="theme-logo h-6 w-6 mr-2" />
<img src="{{ logo_url }}" alt="{{ network_name }}" class="theme-logo{% if logo_invert_light %} theme-logo--invert-light{% endif %} h-6 w-6 mr-2" />
{{ network_name }}
</a>
</div>

View File

@@ -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):