diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index 9cca78e..d3ebdd9 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -55,8 +55,6 @@ async def list_nodes( Node.adv_type == "repeater", Node.adv_type.ilike("%repeater%"), Node.adv_type.ilike("%relay%"), - Node.name.ilike("%repeater%"), - Node.name.ilike("%relay%"), ) ) elif normalized_adv_type == "companion": @@ -65,8 +63,6 @@ async def list_nodes( Node.adv_type == "companion", Node.adv_type.ilike("%companion%"), Node.adv_type.ilike("%observer%"), - Node.name.ilike("%companion%"), - Node.name.ilike("%observer%"), ) ) elif normalized_adv_type == "room": @@ -74,7 +70,6 @@ async def list_nodes( or_( Node.adv_type == "room", Node.adv_type.ilike("%room%"), - Node.name.ilike("%room%"), ) ) elif normalized_adv_type == "chat": diff --git a/src/meshcore_hub/collector/letsmesh_decoder.py b/src/meshcore_hub/collector/letsmesh_decoder.py index 9310d56..b50061d 100644 --- a/src/meshcore_hub/collector/letsmesh_decoder.py +++ b/src/meshcore_hub/collector/letsmesh_decoder.py @@ -183,6 +183,9 @@ class LetsMeshPacketDecoder: clean_hex = raw_hex.strip() if not clean_hex: return None + if not self._is_hex(clean_hex): + logger.debug("LetsMesh decoder skipped non-hex raw payload") + return None cached = self._decode_cache.get(clean_hex) if clean_hex in self._decode_cache: return cached diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 3dbdb8d..4376c63 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -22,6 +22,34 @@ export function getConfig() { return window.__APP_CONFIG__ || {}; } +/** + * Build channel label map from app config. + * Keys are numeric channel indexes and values are non-empty labels. + * + * @param {Object} [config] + * @returns {Map} + */ +export function getChannelLabelsMap(config = getConfig()) { + return new Map( + Object.entries(config.channel_labels || {}) + .map(([idx, label]) => [parseInt(idx, 10), typeof label === 'string' ? label.trim() : '']) + .filter(([idx, label]) => Number.isInteger(idx) && label.length > 0), + ); +} + +/** + * Resolve a channel label from a numeric index. + * + * @param {number|string} channelIdx + * @param {Map} [channelLabels] + * @returns {string|null} + */ +export function resolveChannelLabel(channelIdx, channelLabels = getChannelLabelsMap()) { + const parsed = parseInt(String(channelIdx), 10); + if (!Number.isInteger(parsed)) return null; + return channelLabels.get(parsed) || null; +} + /** * Parse API datetime strings reliably. * MeshCore API often returns UTC timestamps without an explicit timezone suffix. diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js index cad567a..6d1ae1e 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -1,35 +1,17 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, - getConfig, typeEmoji, errorAlert, pageColors, t, formatDateTime, + getConfig, getChannelLabelsMap, resolveChannelLabel, + typeEmoji, errorAlert, pageColors, t, formatDateTime, } from '../components.js'; import { iconNodes, iconAdvertisements, iconMessages, iconChannel, } from '../icons.js'; -function knownChannelLabel(channelIdx) { - const config = getConfig(); - const configuredChannelLabels = new Map( - Object.entries(config.channel_labels || {}) - .map(([idx, label]) => [parseInt(idx, 10), typeof label === 'string' ? label.trim() : '']) - .filter(([idx, label]) => Number.isInteger(idx) && label.length > 0), - ); - const builtInChannelLabels = new Map([ - [17, 'Public'], - [217, '#test'], - [202, '#bot'], - [184, '#chat'], - [159, '#jokes'], - [221, '#sports'], - [104, '#emergency'], - ]); - return configuredChannelLabels.get(channelIdx) || builtInChannelLabels.get(channelIdx) || null; -} - -function channelLabel(channel) { +function channelLabel(channel, channelLabels) { const idx = parseInt(String(channel), 10); if (Number.isInteger(idx)) { - return knownChannelLabel(idx) || `Ch ${idx}`; + return resolveChannelLabel(idx, channelLabels) || `Ch ${idx}`; } return String(channel); } @@ -84,11 +66,11 @@ function renderRecentAds(ads) { `; } -function renderChannelMessages(channelMessages) { +function renderChannelMessages(channelMessages, channelLabels) { if (!channelMessages || Object.keys(channelMessages).length === 0) return nothing; const channels = Object.entries(channelMessages).map(([channel, messages]) => { - const label = channelLabel(channel); + const label = channelLabel(channel, channelLabels); const msgLines = messages.map(msg => html`
${formatTimeShort(msg.received_at)} @@ -127,6 +109,7 @@ function gridCols(count) { export async function render(container, params, router) { try { const config = getConfig(); + const channelLabels = getChannelLabelsMap(config); const features = config.features || {}; const showNodes = features.nodes !== false; const showAdverts = features.advertisements !== false; @@ -242,7 +225,7 @@ ${bottomCount > 0 ? html`
` : nothing} - ${showMessages ? renderChannelMessages(stats.channel_messages) : nothing} + ${showMessages ? renderChannelMessages(stats.channel_messages, channelLabels) : nothing} ` : nothing}`, container); window.initDashboardCharts( diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index 3072078..4ef4bdd 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -2,6 +2,7 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, t, getConfig, formatDateTime, formatDateTimeShort, + getChannelLabelsMap, resolveChannelLabel, truncateKey, errorAlert, pagination, timezoneIndicator, createFilterHandler, autoSubmit, submitOnEnter @@ -16,27 +17,10 @@ export async function render(container, params, router) { const offset = (page - 1) * limit; const config = getConfig(); + const channelLabels = getChannelLabelsMap(config); const tz = config.timezone || ''; const tzBadge = tz && tz !== 'UTC' ? html`${tz}` : nothing; const navigate = (url) => router.navigate(url); - const configuredChannelLabels = new Map( - Object.entries(config.channel_labels || {}) - .map(([idx, label]) => [parseInt(idx, 10), typeof label === 'string' ? label.trim() : '']) - .filter(([idx, label]) => Number.isInteger(idx) && label.length > 0), - ); - const builtInChannelLabels = new Map([ - [17, 'Public'], - [217, '#test'], - [202, '#bot'], - [184, '#chat'], - [159, '#jokes'], - [221, '#sports'], - [104, '#emergency'], - ]); - - function knownChannelLabel(channelIdx) { - return configuredChannelLabels.get(channelIdx) || builtInChannelLabels.get(channelIdx) || null; - } function channelInfo(msg) { if (msg.message_type !== 'channel') { @@ -45,7 +29,7 @@ export async function render(container, params, router) { const rawText = msg.text || ''; const match = rawText.match(/^\[([^\]]+)\]\s+([\s\S]*)$/); if (msg.channel_idx !== null && msg.channel_idx !== undefined) { - const knownLabel = knownChannelLabel(msg.channel_idx); + const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels); if (knownLabel) { return { label: knownLabel, @@ -63,7 +47,7 @@ export async function render(container, params, router) { }; } if (msg.channel_idx !== null && msg.channel_idx !== undefined) { - const knownLabel = knownChannelLabel(msg.channel_idx); + const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels); return { label: knownLabel || `Ch ${msg.channel_idx}`, text: rawText || '-' }; } return { label: t('messages.type_channel'), text: rawText || '-' }; diff --git a/tests/test_api/test_nodes.py b/tests/test_api/test_nodes.py index 1b9f981..9103320 100644 --- a/tests/test_api/test_nodes.py +++ b/tests/test_api/test_nodes.py @@ -105,25 +105,28 @@ class TestListNodesFilters: def test_filter_by_adv_type_matches_legacy_labels( self, client_no_auth, api_db_session ): - """Canonical adv_type filters match legacy LetsMesh values and names.""" + """Canonical adv_type filters match legacy LetsMesh adv_type values only.""" from datetime import datetime, timezone from meshcore_hub.common.models import Node repeater_node = Node( public_key="ab" * 32, - name="Car Relay", adv_type="PyMC-Repeater", first_seen=datetime.now(timezone.utc), ) companion_node = Node( public_key="cd" * 32, - name="YC-Observer", - adv_type="offline", + adv_type="offline companion", first_seen=datetime.now(timezone.utc), ) room_node = Node( public_key="ef" * 32, + adv_type="room server", + first_seen=datetime.now(timezone.utc), + ) + name_only_room_node = Node( + public_key="12" * 32, name="WAL-SE Room Server", adv_type="unknown", first_seen=datetime.now(timezone.utc), @@ -131,6 +134,7 @@ class TestListNodesFilters: api_db_session.add(repeater_node) api_db_session.add(companion_node) api_db_session.add(room_node) + api_db_session.add(name_only_room_node) api_db_session.commit() response = client_no_auth.get("/api/v1/nodes?adv_type=repeater") @@ -147,6 +151,7 @@ class TestListNodesFilters: assert response.status_code == 200 room_keys = {item["public_key"] for item in response.json()["items"]} assert room_node.public_key in room_keys + assert name_only_room_node.public_key not in room_keys def test_filter_by_member_id(self, client_no_auth, sample_node_with_member_tag): """Test filtering nodes by member_id tag.""" diff --git a/tests/test_collector/test_letsmesh_decoder.py b/tests/test_collector/test_letsmesh_decoder.py index e624968..4498fbe 100644 --- a/tests/test_collector/test_letsmesh_decoder.py +++ b/tests/test_collector/test_letsmesh_decoder.py @@ -12,6 +12,19 @@ def test_decode_payload_returns_none_without_raw() -> None: assert decoder.decode_payload({"packet_type": 5}) is None +def test_decode_payload_rejects_non_hex_raw_without_invoking_decoder() -> None: + """Decoder returns None and does not execute subprocess for invalid raw hex.""" + decoder = LetsMeshPacketDecoder(enabled=True, command="meshcore-decoder") + + with ( + patch("meshcore_hub.collector.letsmesh_decoder.shutil.which", return_value="1"), + patch("meshcore_hub.collector.letsmesh_decoder.subprocess.run") as mock_run, + ): + assert decoder.decode_payload({"raw": "ZZ-not-hex"}) is None + + mock_run.assert_not_called() + + def test_decode_payload_invokes_decoder_with_keys() -> None: """Decoder command includes channel keys and returns parsed JSON.""" decoder = LetsMeshPacketDecoder(