mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Refine LetsMesh status ingest and custom logo behavior
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
!example/data/
|
||||
/seed/
|
||||
!example/seed/
|
||||
/content/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user