From 3cfa0db7e6bf17bfc51013731b7136fb3310dc51 Mon Sep 17 00:00:00 2001
From: l5y <220195275+l5yth@users.noreply.github.com>
Date: Sat, 4 Apr 2026 17:14:16 +0200
Subject: [PATCH] web: distinguish meshcore from meshtastic in frontend (#688)
* web: distinguish meshcore from meshtastic in frontend
* fix mark_packet_seen bug
* web: distinguish meshcore from meshtastic in frontend
* address review comments
* address review comments
* address review comments
---
data/mesh_ingestor/handlers/__init__.py | 2 +
data/mesh_ingestor/providers/meshcore.py | 2 +
tests/test_handlers_unit.py | 7 +
tests/test_provider_unit.py | 12 +
.../js/app/__tests__/main-filter.test.js | 534 ++++++++++++++++++
.../js/app/__tests__/main-protocol.test.js | 27 +-
.../js/app/__tests__/node-details.test.js | 26 +
.../app/__tests__/node-link-helpers.test.js | 15 +-
.../assets/js/app/__tests__/node-page.test.js | 19 +-
.../js/app/__tests__/node-rendering.test.js | 30 +-
.../js/app/__tests__/protocol-helpers.test.js | 51 +-
.../js/app/__tests__/role-helpers.test.js | 78 +++
web/public/assets/js/app/main.js | 343 ++++++++---
web/public/assets/js/app/node-details.js | 1 +
web/public/assets/js/app/node-rendering.js | 6 +-
web/public/assets/js/app/protocol-helpers.js | 22 +-
web/public/assets/js/app/role-helpers.js | 104 +++-
web/public/assets/styles/base.css | 52 +-
web/views/shared/_nodes_table.erb | 1 +
19 files changed, 1163 insertions(+), 169 deletions(-)
create mode 100644 web/public/assets/js/app/__tests__/main-filter.test.js
diff --git a/data/mesh_ingestor/handlers/__init__.py b/data/mesh_ingestor/handlers/__init__.py
index 820b1d5..9cae0ca 100644
--- a/data/mesh_ingestor/handlers/__init__.py
+++ b/data/mesh_ingestor/handlers/__init__.py
@@ -34,6 +34,7 @@ from __future__ import annotations
from .. import queue as _queue
from ._state import (
+ _mark_packet_seen,
host_node_id,
last_packet_monotonic,
register_host_node_id,
@@ -85,6 +86,7 @@ __all__ = [
"_radio_metadata_fields",
"_record_ignored_packet",
"base64_payload",
+ "_mark_packet_seen",
"host_node_id",
"last_packet_monotonic",
"on_receive",
diff --git a/data/mesh_ingestor/providers/meshcore.py b/data/mesh_ingestor/providers/meshcore.py
index 0df3d2a..86dc515 100644
--- a/data/mesh_ingestor/providers/meshcore.py
+++ b/data/mesh_ingestor/providers/meshcore.py
@@ -219,6 +219,7 @@ def _contact_to_node_dict(contact: dict) -> dict:
role = _meshcore_adv_type_to_role(contact.get("type"))
node: dict = {
"lastHeard": contact.get("last_advert"),
+ "protocol": "meshcore",
"user": {
"longName": name,
"shortName": _meshcore_short_name(pub_key),
@@ -249,6 +250,7 @@ def _self_info_to_node_dict(self_info: dict) -> dict:
role = _meshcore_adv_type_to_role(self_info.get("adv_type"))
node: dict = {
"lastHeard": int(time.time()),
+ "protocol": "meshcore",
"user": {
"longName": name,
"shortName": _meshcore_short_name(pub_key),
diff --git a/tests/test_handlers_unit.py b/tests/test_handlers_unit.py
index 73739b5..2ab6f67 100644
--- a/tests/test_handlers_unit.py
+++ b/tests/test_handlers_unit.py
@@ -100,6 +100,13 @@ class TestLastPacketMonotonic:
assert ts is not None
assert isinstance(ts, float)
+ def test_mark_packet_seen_exported_from_handlers(self):
+ """handlers._mark_packet_seen must be accessible via the package."""
+ assert callable(handlers._mark_packet_seen)
+ handlers._mark_packet_seen()
+ ts = handlers.last_packet_monotonic()
+ assert ts is not None
+
# ---------------------------------------------------------------------------
# _state: _host_telemetry_suppressed
diff --git a/tests/test_provider_unit.py b/tests/test_provider_unit.py
index 6e32925..a57a875 100644
--- a/tests/test_provider_unit.py
+++ b/tests/test_provider_unit.py
@@ -668,6 +668,12 @@ def test_contact_to_node_dict_omits_position_at_origin():
assert "position" not in node
+def test_contact_to_node_dict_sets_protocol_meshcore():
+ """_contact_to_node_dict must set protocol='meshcore' on every node dict."""
+ contact = {"public_key": "aa" * 32, "adv_name": "Node"}
+ assert _contact_to_node_dict(contact)["protocol"] == "meshcore"
+
+
# ---------------------------------------------------------------------------
# _self_info_to_node_dict
# ---------------------------------------------------------------------------
@@ -709,6 +715,12 @@ def test_self_info_to_node_dict_includes_position():
assert node["position"]["longitude"] == pytest.approx(2.35)
+def test_self_info_to_node_dict_sets_protocol_meshcore():
+ """_self_info_to_node_dict must set protocol='meshcore' on the node dict."""
+ self_info = {"name": "MyNode", "public_key": "bb" * 32}
+ assert _self_info_to_node_dict(self_info)["protocol"] == "meshcore"
+
+
# ---------------------------------------------------------------------------
# _MeshcoreInterface contact management
# ---------------------------------------------------------------------------
diff --git a/web/public/assets/js/app/__tests__/main-filter.test.js b/web/public/assets/js/app/__tests__/main-filter.test.js
new file mode 100644
index 0000000..714f647
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/main-filter.test.js
@@ -0,0 +1,534 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { createDomEnvironment } from './dom-environment.js';
+import { initializeApp } from '../main.js';
+
+const MINIMAL_CONFIG = Object.freeze({
+ channel: 'Primary',
+ frequency: '915MHz',
+ refreshMs: 0,
+ refreshIntervalSeconds: 30,
+ chatEnabled: true,
+ mapCenter: { lat: 0, lon: 0 },
+ mapZoom: null,
+ maxDistanceKm: 0,
+ tileFilters: { light: '', dark: '' },
+ instancesFeatureEnabled: false,
+ instanceDomain: null,
+ snapshotWindowSeconds: 3600,
+});
+
+/**
+ * Spin up a minimal app and return test utilities with a cleanup handle.
+ *
+ * @returns {{ testUtils: Object, cleanup: Function }}
+ */
+function setupApp() {
+ const env = createDomEnvironment({ includeBody: true });
+ env.createElement('button', 'themeToggle');
+ const { _testUtils } = initializeApp(MINIMAL_CONFIG);
+ return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) };
+}
+
+/**
+ * Run a test body with a fresh app instance, ensuring cleanup regardless of outcome.
+ *
+ * @param {function(Object): void} fn Receives the _testUtils object.
+ */
+function withApp(fn) {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ fn(testUtils);
+ } finally {
+ cleanup();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// makeRoleFilterKey
+// ---------------------------------------------------------------------------
+
+test('makeRoleFilterKey produces compound key for meshtastic protocol', () => {
+ withApp((t) => {
+ assert.equal(t.makeRoleFilterKey('SENSOR', 'meshtastic'), 'meshtastic:SENSOR');
+ assert.equal(t.makeRoleFilterKey('ROUTER', 'meshtastic'), 'meshtastic:ROUTER');
+ });
+});
+
+test('makeRoleFilterKey produces compound key for meshcore protocol', () => {
+ withApp((t) => {
+ assert.equal(t.makeRoleFilterKey('SENSOR', 'meshcore'), 'meshcore:SENSOR');
+ assert.equal(t.makeRoleFilterKey('REPEATER', 'meshcore'), 'meshcore:REPEATER');
+ });
+});
+
+test('makeRoleFilterKey defaults null protocol to meshtastic bucket', () => {
+ withApp((t) => {
+ assert.equal(t.makeRoleFilterKey('SENSOR', null), 'meshtastic:SENSOR');
+ assert.equal(t.makeRoleFilterKey('ROUTER', null), 'meshtastic:ROUTER');
+ });
+});
+
+test('makeRoleFilterKey defaults absent protocol to meshtastic bucket', () => {
+ withApp((t) => {
+ assert.equal(t.makeRoleFilterKey('CLIENT', undefined), 'meshtastic:CLIENT');
+ });
+});
+
+test('makeRoleFilterKey SENSOR and REPEATER produce distinct keys across protocols', () => {
+ withApp((t) => {
+ const meshtasticSensor = t.makeRoleFilterKey('SENSOR', 'meshtastic');
+ const meshcoreSensor = t.makeRoleFilterKey('SENSOR', 'meshcore');
+ assert.notEqual(meshtasticSensor, meshcoreSensor);
+
+ const meshtasticRepeater = t.makeRoleFilterKey('REPEATER', 'meshtastic');
+ const meshcoreRepeater = t.makeRoleFilterKey('REPEATER', 'meshcore');
+ assert.notEqual(meshtasticRepeater, meshcoreRepeater);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// matchesRoleFilter — no active filters
+// ---------------------------------------------------------------------------
+
+test('matchesRoleFilter returns true when no filters are active', () => {
+ withApp((t) => {
+ t.activeRoleFilters.clear();
+ assert.equal(t.matchesRoleFilter({ role: 'ROUTER', protocol: 'meshtastic' }), true);
+ assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// matchesRoleFilter — protocol-aware compound key matching
+// ---------------------------------------------------------------------------
+
+test('matchesRoleFilter matches meshtastic SENSOR filter for meshtastic node', () => {
+ withApp((t) => {
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add('meshtastic:SENSOR');
+ assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), true);
+ });
+});
+
+test('matchesRoleFilter does not match meshtastic SENSOR filter for meshcore node', () => {
+ withApp((t) => {
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add('meshtastic:SENSOR');
+ assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), false);
+ });
+});
+
+test('matchesRoleFilter matches meshcore SENSOR filter for meshcore node', () => {
+ withApp((t) => {
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add('meshcore:SENSOR');
+ assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), true);
+ });
+});
+
+test('matchesRoleFilter does not match meshcore SENSOR filter for meshtastic node', () => {
+ withApp((t) => {
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add('meshcore:SENSOR');
+ assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), false);
+ });
+});
+
+test('matchesRoleFilter matches meshtastic REPEATER filter for meshtastic node', () => {
+ withApp((t) => {
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add('meshtastic:REPEATER');
+ assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshtastic' }), true);
+ assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), false);
+ });
+});
+
+test('matchesRoleFilter matches meshcore REPEATER filter for meshcore node', () => {
+ withApp((t) => {
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add('meshcore:REPEATER');
+ assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), true);
+ assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshtastic' }), false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// matchesRoleFilter — null/absent protocol treated as meshtastic
+// ---------------------------------------------------------------------------
+
+test('matchesRoleFilter treats null protocol as meshtastic for filter matching', () => {
+ withApp((t) => {
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add('meshtastic:SENSOR');
+ // null-protocol node should match the meshtastic SENSOR filter
+ assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: null }), true);
+ // but not the meshcore one
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add('meshcore:SENSOR');
+ assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: null }), false);
+ });
+});
+
+test('matchesRoleFilter with multiple active filters returns true when any matches', () => {
+ withApp((t) => {
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add('meshtastic:SENSOR');
+ t.activeRoleFilters.add('meshcore:REPEATER');
+ assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), true);
+ assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), true);
+ assert.equal(t.matchesRoleFilter({ role: 'ROUTER', protocol: 'meshtastic' }), false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// matchesProtocolFilter
+// ---------------------------------------------------------------------------
+
+test('matchesProtocolFilter returns true when no protocols are hidden', () => {
+ withApp((t) => {
+ t.hiddenProtocols.clear();
+ assert.equal(t.matchesProtocolFilter({ protocol: 'meshtastic' }), true);
+ assert.equal(t.matchesProtocolFilter({ protocol: 'meshcore' }), true);
+ assert.equal(t.matchesProtocolFilter({ protocol: null }), true);
+ });
+});
+
+test('matchesProtocolFilter hides meshtastic nodes when meshtastic is hidden', () => {
+ withApp((t) => {
+ t.hiddenProtocols.clear();
+ t.hiddenProtocols.add('meshtastic');
+ assert.equal(t.matchesProtocolFilter({ protocol: 'meshtastic' }), false);
+ assert.equal(t.matchesProtocolFilter({ protocol: 'meshcore' }), true);
+ });
+});
+
+test('matchesProtocolFilter hides meshcore nodes when meshcore is hidden', () => {
+ withApp((t) => {
+ t.hiddenProtocols.clear();
+ t.hiddenProtocols.add('meshcore');
+ assert.equal(t.matchesProtocolFilter({ protocol: 'meshcore' }), false);
+ assert.equal(t.matchesProtocolFilter({ protocol: 'meshtastic' }), true);
+ });
+});
+
+test('matchesProtocolFilter always shows null-protocol nodes even when meshtastic is hidden', () => {
+ withApp((t) => {
+ t.hiddenProtocols.clear();
+ t.hiddenProtocols.add('meshtastic');
+ // null/absent protocol nodes are NOT hidden — they predate the protocol field
+ assert.equal(t.matchesProtocolFilter({ protocol: null }), true);
+ assert.equal(t.matchesProtocolFilter({}), true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Role filter key independence across protocols
+// ---------------------------------------------------------------------------
+
+test('SENSOR filter keys for meshtastic and meshcore are distinct strings', () => {
+ withApp((t) => {
+ const m = t.makeRoleFilterKey('SENSOR', 'meshtastic');
+ const mc = t.makeRoleFilterKey('SENSOR', 'meshcore');
+ // They must be different keys so they can live independently in the Set
+ assert.notEqual(m, mc);
+ // Adding one to the filter set must not affect the other
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add(m);
+ assert.equal(t.activeRoleFilters.has(m), true);
+ assert.equal(t.activeRoleFilters.has(mc), false);
+ });
+});
+
+test('REPEATER filter keys for meshtastic and meshcore are distinct strings', () => {
+ withApp((t) => {
+ const m = t.makeRoleFilterKey('REPEATER', 'meshtastic');
+ const mc = t.makeRoleFilterKey('REPEATER', 'meshcore');
+ assert.notEqual(m, mc);
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add(mc);
+ assert.equal(t.activeRoleFilters.has(mc), true);
+ assert.equal(t.activeRoleFilters.has(m), false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// normalizeFilterProtocol
+// ---------------------------------------------------------------------------
+
+test('normalizeFilterProtocol returns meshcore for explicit meshcore', () => {
+ withApp((t) => {
+ assert.equal(t.normalizeFilterProtocol('meshcore'), 'meshcore');
+ });
+});
+
+test('normalizeFilterProtocol returns meshtastic for explicit meshtastic', () => {
+ withApp((t) => {
+ assert.equal(t.normalizeFilterProtocol('meshtastic'), 'meshtastic');
+ });
+});
+
+test('normalizeFilterProtocol returns meshtastic for null', () => {
+ withApp((t) => {
+ assert.equal(t.normalizeFilterProtocol(null), 'meshtastic');
+ });
+});
+
+test('normalizeFilterProtocol returns meshtastic for undefined', () => {
+ withApp((t) => {
+ assert.equal(t.normalizeFilterProtocol(undefined), 'meshtastic');
+ });
+});
+
+test('normalizeFilterProtocol returns meshtastic for unknown protocol', () => {
+ withApp((t) => {
+ assert.equal(t.normalizeFilterProtocol('reticulum'), 'meshtastic');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// buildProtocolIconImg / buildMeshtasticIconImg / buildMeshcoreIconImg
+// ---------------------------------------------------------------------------
+
+test('buildProtocolIconImg returns an img element with the correct src and class', () => {
+ withApp((t) => {
+ const img = t.buildProtocolIconImg('/assets/img/test.svg', 'protocol-icon--test');
+ assert.equal(img.tagName.toLowerCase(), 'img');
+ assert.equal(img.getAttribute('src'), '/assets/img/test.svg');
+ assert.ok(img.className.includes('protocol-icon'));
+ assert.ok(img.className.includes('protocol-icon--test'));
+ assert.equal(img.getAttribute('aria-hidden'), 'true');
+ assert.equal(img.getAttribute('alt'), '');
+ assert.equal(img.getAttribute('width'), '12');
+ assert.equal(img.getAttribute('height'), '12');
+ });
+});
+
+test('buildMeshtasticIconImg references meshtastic.svg and carries the meshtastic class', () => {
+ withApp((t) => {
+ const img = t.buildMeshtasticIconImg();
+ assert.ok(img.getAttribute('src').includes('meshtastic.svg'));
+ assert.ok(img.className.includes('protocol-icon--meshtastic'));
+ assert.equal(img.getAttribute('aria-hidden'), 'true');
+ });
+});
+
+test('buildMeshcoreIconImg references meshcore.svg and carries the meshcore class', () => {
+ withApp((t) => {
+ const img = t.buildMeshcoreIconImg();
+ assert.ok(img.getAttribute('src').includes('meshcore.svg'));
+ assert.ok(img.className.includes('protocol-icon--meshcore'));
+ assert.equal(img.getAttribute('aria-hidden'), 'true');
+ });
+});
+
+test('buildMeshtasticIconImg and buildMeshcoreIconImg return different src values', () => {
+ withApp((t) => {
+ const mt = t.buildMeshtasticIconImg();
+ const mc = t.buildMeshcoreIconImg();
+ assert.notEqual(mt.getAttribute('src'), mc.getAttribute('src'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// legendClickHandler
+// ---------------------------------------------------------------------------
+
+test('legendClickHandler calls preventDefault and stopPropagation before fn', () => {
+ withApp((t) => {
+ let fnCalled = false;
+ let preventDefaultCalled = false;
+ let stopPropagationCalled = false;
+ const handler = t.legendClickHandler(() => { fnCalled = true; });
+ const fakeEvent = {
+ preventDefault: () => { preventDefaultCalled = true; },
+ stopPropagation: () => { stopPropagationCalled = true; },
+ };
+ handler(fakeEvent);
+ assert.equal(preventDefaultCalled, true);
+ assert.equal(stopPropagationCalled, true);
+ assert.equal(fnCalled, true);
+ });
+});
+
+test('legendClickHandler passes the event to fn', () => {
+ withApp((t) => {
+ let received = null;
+ const handler = t.legendClickHandler(ev => { received = ev; });
+ const fakeEvent = {
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ detail: 'test',
+ };
+ handler(fakeEvent);
+ assert.equal(received, fakeEvent);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// buildRoleButtons
+// ---------------------------------------------------------------------------
+
+test('buildRoleButtons appends one child per palette entry', () => {
+ withApp((t) => {
+ t.legendRoleButtons.clear();
+ const col = document.createElement('div');
+ t.buildRoleButtons(col, { SENSOR: '#4A7EB4', REPEATER: '#C8D0DC' }, 'meshcore');
+ assert.equal(col.childNodes.length, 2);
+ });
+});
+
+test('buildRoleButtons sets dataset.role and dataset.protocol on each button', () => {
+ withApp((t) => {
+ t.legendRoleButtons.clear();
+ const col = document.createElement('div');
+ t.buildRoleButtons(col, { SENSOR: '#4A7EB4' }, 'meshcore');
+ const btn = t.legendRoleButtons.get('meshcore:SENSOR');
+ assert.ok(btn, 'button should be in legendRoleButtons');
+ assert.equal(btn.dataset.role, 'SENSOR');
+ assert.equal(btn.dataset.protocol, 'meshcore');
+ });
+});
+
+test('buildRoleButtons registers compound keys in legendRoleButtons', () => {
+ withApp((t) => {
+ t.legendRoleButtons.clear();
+ const col = document.createElement('div');
+ t.buildRoleButtons(col, { SENSOR: '#4A7EB4', REPEATER: '#C8D0DC' }, 'meshcore');
+ assert.ok(t.legendRoleButtons.has('meshcore:SENSOR'));
+ assert.ok(t.legendRoleButtons.has('meshcore:REPEATER'));
+ });
+});
+
+test('buildRoleButtons keeps meshtastic and meshcore SENSOR keys distinct', () => {
+ withApp((t) => {
+ t.legendRoleButtons.clear();
+ const colMc = document.createElement('div');
+ const colMt = document.createElement('div');
+ t.buildRoleButtons(colMc, { SENSOR: '#4A7EB4' }, 'meshcore');
+ t.buildRoleButtons(colMt, { SENSOR: '#B2D880' }, 'meshtastic');
+ assert.ok(t.legendRoleButtons.has('meshcore:SENSOR'));
+ assert.ok(t.legendRoleButtons.has('meshtastic:SENSOR'));
+ assert.notEqual(
+ t.legendRoleButtons.get('meshcore:SENSOR'),
+ t.legendRoleButtons.get('meshtastic:SENSOR'),
+ );
+ });
+});
+
+test('buildRoleButtons sets aria-pressed to false initially', () => {
+ withApp((t) => {
+ t.legendRoleButtons.clear();
+ const col = document.createElement('div');
+ t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic');
+ const btn = t.legendRoleButtons.get('meshtastic:ROUTER');
+ assert.ok(btn, 'button should be in legendRoleButtons');
+ assert.equal(btn.getAttribute('aria-pressed'), 'false');
+ });
+});
+
+test('buildRoleButtons creates swatch child with background color', () => {
+ withApp((t) => {
+ t.legendRoleButtons.clear();
+ const col = document.createElement('div');
+ t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic');
+ const btn = t.legendRoleButtons.get('meshtastic:ROUTER');
+ // swatch is the first child of the button
+ const swatch = btn.childNodes[0];
+ assert.ok(swatch, 'swatch element should exist');
+ assert.ok(swatch.style.background, 'swatch should have background color');
+ });
+});
+
+test('buildRoleButtons creates label child with role text', () => {
+ withApp((t) => {
+ t.legendRoleButtons.clear();
+ const col = document.createElement('div');
+ t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic');
+ const btn = t.legendRoleButtons.get('meshtastic:ROUTER');
+ // label is the second child of the button
+ const label = btn.childNodes[1];
+ assert.ok(label, 'label element should exist');
+ assert.equal(label.textContent, 'ROUTER');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// updateLegendRoleFiltersUI
+// ---------------------------------------------------------------------------
+
+test('updateLegendRoleFiltersUI sets aria-pressed true on active role buttons', () => {
+ withApp((t) => {
+ t.legendRoleButtons.clear();
+ const col = document.createElement('div');
+ t.buildRoleButtons(col, { SENSOR: '#4A7EB4' }, 'meshcore');
+ const btn = t.legendRoleButtons.get('meshcore:SENSOR');
+ t.activeRoleFilters.clear();
+ t.activeRoleFilters.add('meshcore:SENSOR');
+ t.updateLegendRoleFiltersUI();
+ assert.equal(btn.getAttribute('aria-pressed'), 'true');
+ });
+});
+
+test('updateLegendRoleFiltersUI sets aria-pressed false on inactive role buttons', () => {
+ withApp((t) => {
+ t.legendRoleButtons.clear();
+ const col = document.createElement('div');
+ t.buildRoleButtons(col, { SENSOR: '#4A7EB4' }, 'meshcore');
+ const btn = t.legendRoleButtons.get('meshcore:SENSOR');
+ t.activeRoleFilters.clear();
+ t.updateLegendRoleFiltersUI();
+ assert.equal(btn.getAttribute('aria-pressed'), 'false');
+ });
+});
+
+test('updateLegendRoleFiltersUI updates protocol button text to Show when hidden', () => {
+ withApp((t) => {
+ t.legendProtocolButtons.clear();
+ const fakeBtn = document.createElement('button');
+ fakeBtn.setAttribute('aria-pressed', 'false');
+ t.legendProtocolButtons.set('meshtastic', fakeBtn);
+ t.hiddenProtocols.clear();
+ t.hiddenProtocols.add('meshtastic');
+ t.updateLegendRoleFiltersUI();
+ assert.equal(fakeBtn.getAttribute('aria-pressed'), 'true');
+ assert.ok(fakeBtn.textContent.startsWith('Show'));
+ });
+});
+
+test('updateLegendRoleFiltersUI updates protocol button text to Hide when visible', () => {
+ withApp((t) => {
+ t.legendProtocolButtons.clear();
+ const fakeBtn = document.createElement('button');
+ fakeBtn.setAttribute('aria-pressed', 'true');
+ t.legendProtocolButtons.set('meshcore', fakeBtn);
+ t.hiddenProtocols.clear();
+ t.updateLegendRoleFiltersUI();
+ assert.equal(fakeBtn.getAttribute('aria-pressed'), 'false');
+ assert.ok(fakeBtn.textContent.startsWith('Hide'));
+ });
+});
+
+test('updateLegendRoleFiltersUI is safe when legendContainer is null', () => {
+ withApp((t) => {
+ // legendContainer starts null in tests (no map); should not throw
+ assert.doesNotThrow(() => t.updateLegendRoleFiltersUI());
+ });
+});
diff --git a/web/public/assets/js/app/__tests__/main-protocol.test.js b/web/public/assets/js/app/__tests__/main-protocol.test.js
index 1fc0ce5..6abd1bf 100644
--- a/web/public/assets/js/app/__tests__/main-protocol.test.js
+++ b/web/public/assets/js/app/__tests__/main-protocol.test.js
@@ -140,17 +140,26 @@ test('normalizeOverlaySource omits protocol when value is not a string', () => {
// --- buildMapPopupHtml ---
-test('buildMapPopupHtml includes meshtastic icon for null protocol', () => {
+test('buildMapPopupHtml shows no icon for null protocol', () => {
withApp((t) => {
const html = t.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: null }, 0);
- assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon for null protocol');
+ assert.ok(!html.includes('meshtastic.svg'), 'popup should not show meshtastic icon when protocol is null');
+ assert.ok(!html.includes('meshcore.svg'), 'popup should not show meshcore icon when protocol is null');
});
});
-test('buildMapPopupHtml includes meshtastic icon for absent protocol', () => {
+test('buildMapPopupHtml shows no icon when protocol is absent', () => {
withApp((t) => {
const html = t.buildMapPopupHtml({ long_name: 'Bob', node_id: '!abc456' }, 0);
- assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon when protocol absent');
+ assert.ok(!html.includes('meshtastic.svg'), 'popup should not show any icon when protocol is absent');
+ assert.ok(!html.includes('meshcore.svg'), 'popup should not show any icon when protocol is absent');
+ });
+});
+
+test('buildMapPopupHtml shows meshtastic icon for explicit meshtastic protocol', () => {
+ withApp((t) => {
+ const html = t.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: 'meshtastic' }, 0);
+ assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon for explicit meshtastic protocol');
});
});
@@ -178,7 +187,7 @@ test('createAnnouncementEntry prefixes meshtastic icon when protocol is meshtast
});
});
-test('createAnnouncementEntry prefixes meshtastic icon when protocol is absent', () => {
+test('createAnnouncementEntry shows no icon when protocol is absent', () => {
withApp((t) => {
const div = t.createAnnouncementEntry({
timestampSeconds: 1000,
@@ -189,7 +198,8 @@ test('createAnnouncementEntry prefixes meshtastic icon when protocol is absent',
nodeData: null,
messageHtml: 'detected',
});
- assert.ok(innerHtml(div).includes('meshtastic.svg'), 'announcement without protocol should show meshtastic icon');
+ assert.ok(!innerHtml(div).includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent');
+ assert.ok(!innerHtml(div).includes('meshcore.svg'), 'no meshcore icon when protocol is absent');
});
});
@@ -237,14 +247,15 @@ test('createMessageChatEntry prefixes meshtastic icon when node protocol is mesh
});
});
-test('createMessageChatEntry prefixes meshtastic icon when node protocol is absent', () => {
+test('createMessageChatEntry shows no icon when node protocol is absent', () => {
withApp((t) => {
const div = t.createMessageChatEntry({
text: 'hi',
rx_time: 2000,
node: { short_name: 'BOB', role: 'ROUTER' },
});
- assert.ok(innerHtml(div).includes('meshtastic.svg'), 'chat entry without protocol should show meshtastic icon');
+ assert.ok(!innerHtml(div).includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent');
+ assert.ok(!innerHtml(div).includes('meshcore.svg'), 'no meshcore icon when protocol is absent');
});
});
diff --git a/web/public/assets/js/app/__tests__/node-details.test.js b/web/public/assets/js/app/__tests__/node-details.test.js
index 9181f57..e419e37 100644
--- a/web/public/assets/js/app/__tests__/node-details.test.js
+++ b/web/public/assets/js/app/__tests__/node-details.test.js
@@ -336,6 +336,7 @@ test('merge helpers combine node, telemetry, and position data', () => {
assert.equal(node.nodeId, '!node');
assert.equal(node.nodeNum, 55);
assert.equal(node.shortName, 'NODE');
+ assert.equal(node.protocol, undefined); // no protocol in this fixture
assert.equal(node.battery, 50);
assert.equal(node.voltage, 3.8);
assert.equal(node.lastHeard, 1_200);
@@ -350,6 +351,31 @@ test('merge helpers combine node, telemetry, and position data', () => {
assert.ok(node.position);
});
+test('mergeNodeFields propagates the protocol field', () => {
+ const node = {};
+ mergeNodeFields(node, { node_id: '!abc', protocol: 'meshcore' });
+ assert.equal(node.protocol, 'meshcore');
+});
+
+test('mergeNodeFields does not overwrite an existing protocol with absent value', () => {
+ const node = { protocol: 'meshcore' };
+ mergeNodeFields(node, { node_id: '!abc' });
+ assert.equal(node.protocol, 'meshcore');
+});
+
+test('refreshNodeInformation surfaces protocol from the node API record', async () => {
+ const responses = new Map([
+ ['/api/nodes/!proto?limit=7', createResponse(200, [{ node_id: '!proto', short_name: 'PT', protocol: 'meshcore' }])],
+ ['/api/telemetry/!proto?limit=1000', createResponse(404, {})],
+ ['/api/positions/!proto?limit=7', createResponse(404, {})],
+ ['/api/neighbors/!proto?limit=1000', createResponse(404, {})],
+ ]);
+ const fetchImpl = async url => responses.get(url) ?? createResponse(404, {});
+ const node = await refreshNodeInformation('!proto', { fetchImpl });
+ assert.equal(node.protocol, 'meshcore');
+ assert.equal(node.rawSources.node.protocol, 'meshcore');
+});
+
test('normalizeReference extracts identifiers and tolerates malformed fallback payloads', () => {
const originalWarn = console.warn;
const warnings = [];
diff --git a/web/public/assets/js/app/__tests__/node-link-helpers.test.js b/web/public/assets/js/app/__tests__/node-link-helpers.test.js
index 71ad0bc..6294c3e 100644
--- a/web/public/assets/js/app/__tests__/node-link-helpers.test.js
+++ b/web/public/assets/js/app/__tests__/node-link-helpers.test.js
@@ -97,9 +97,10 @@ test('renderNodeLongNameLink returns empty string for null/empty longName', () =
assert.equal(renderNodeLongNameLink(' ', '!abc123'), '');
});
-test('renderNodeLongNameLink prepends Meshtastic icon for null protocol', () => {
+test('renderNodeLongNameLink shows no icon for null protocol', () => {
const html = renderNodeLongNameLink('Alice', '!abc123', { protocol: null });
- assert.ok(html.includes('meshtastic.svg'), 'should include meshtastic icon');
+ assert.ok(!html.includes('meshtastic.svg'), 'no meshtastic icon for null protocol');
+ assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon for null protocol');
assert.ok(html.includes('Alice'), 'should include the name');
});
@@ -108,9 +109,10 @@ test('renderNodeLongNameLink prepends Meshtastic icon for "meshtastic" protocol'
assert.ok(html.includes('meshtastic.svg'), 'should include meshtastic icon');
});
-test('renderNodeLongNameLink prepends Meshtastic icon for undefined protocol (default)', () => {
+test('renderNodeLongNameLink shows no icon for absent protocol (default)', () => {
const html = renderNodeLongNameLink('Alice', '!abc123');
- assert.ok(html.includes('meshtastic.svg'), 'default protocol should show meshtastic icon');
+ assert.ok(!html.includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent');
+ assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon when protocol is absent');
});
test('renderNodeLongNameLink does not prepend icon for "meshcore" protocol', () => {
@@ -133,11 +135,12 @@ test('renderNodeLongNameLink renders anchor with href when identifier is present
assert.ok(html.includes('data-node-id="!abc123"'), 'should include node id attribute');
});
-test('renderNodeLongNameLink renders plain text (with icon) when no identifier', () => {
+test('renderNodeLongNameLink renders plain text (no icon) when no identifier and null protocol', () => {
const html = renderNodeLongNameLink('Alice', null, { protocol: null });
assert.ok(!html.startsWith(' {
diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js
index 4341f6f..1b1b4ca 100644
--- a/web/public/assets/js/app/__tests__/node-page.test.js
+++ b/web/public/assets/js/app/__tests__/node-page.test.js
@@ -360,7 +360,7 @@ test('renderSingleNodeTable renders a condensed table for the node', () => {
10_000,
);
assert.equal(html.includes('
.*Example Node<\/a>/s);
assert.equal(html.includes('66.0%'), true);
assert.equal(html.includes('1.230%'), true);
@@ -604,7 +604,7 @@ test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
assert.equal(html.includes('Heard by'), true);
assert.equal(html.includes('We hear'), true);
assert.equal(html.includes('Messages'), true);
- assert.ok(html.includes('meshtastic.svg'), 'default protocol should show meshtastic icon in heading and table');
+ assert.ok(!html.includes('meshtastic.svg'), 'absent protocol should show no meshtastic icon in heading and table');
assert.match(html, /.*Example Node<\/a>/s);
assert.equal(html.includes('PEER'), true);
assert.equal(html.includes('ALLY'), true);
@@ -665,7 +665,7 @@ test('renderSingleNodeTable shows meshtastic icon for meshtastic protocol in lon
assert.ok(html.includes('meshtastic.svg'), 'meshtastic protocol should show icon in long name link');
});
-test('renderSingleNodeTable shows meshtastic icon when protocol is absent in long name link', () => {
+test('renderSingleNodeTable shows no protocol icon when protocol is absent in long name link', () => {
const node = {
shortName: 'A',
longName: 'Alice',
@@ -674,7 +674,8 @@ test('renderSingleNodeTable shows meshtastic icon when protocol is absent in lon
rawSources: { node: { node_id: '!aa', role: 'CLIENT' } },
};
const html = renderSingleNodeTable(node, (short, role) => `${short}`, 0);
- assert.ok(html.includes('meshtastic.svg'), 'absent protocol should show meshtastic icon in long name link');
+ assert.ok(!html.includes('meshtastic.svg'), 'absent protocol should show no meshtastic icon in long name link');
+ assert.ok(!html.includes('meshcore.svg'), 'absent protocol should show no meshcore icon in long name link');
});
test('renderSingleNodeTable omits meshtastic icon for meshcore protocol in long name link', () => {
@@ -700,12 +701,13 @@ test('renderNodeDetailHtml shows meshtastic icon in heading for meshtastic proto
assert.ok(html.includes('meshtastic.svg'), 'meshtastic protocol should show icon in heading');
});
-test('renderNodeDetailHtml shows meshtastic icon in heading when protocol is absent', () => {
+test('renderNodeDetailHtml shows no protocol icon in heading when protocol is absent', () => {
const html = renderNodeDetailHtml(
{ shortName: 'A', longName: 'Alice', nodeId: '!aa', role: 'CLIENT' },
{ renderShortHtml: short => `${short}` },
);
- assert.ok(html.includes('meshtastic.svg'), 'absent protocol should show meshtastic icon in heading');
+ assert.ok(!html.includes('meshtastic.svg'), 'absent protocol should show no meshtastic icon in heading');
+ assert.ok(!html.includes('meshcore.svg'), 'absent protocol should show no meshcore icon in heading');
});
test('renderNodeDetailHtml omits meshtastic icon in heading for meshcore protocol', () => {
@@ -736,7 +738,7 @@ test('renderMessages prefixes meshtastic icon for meshtastic node protocol', ()
assert.ok(html.includes('meshtastic.svg'), 'meshtastic node chat entry should show icon');
});
-test('renderMessages prefixes meshtastic icon when node protocol is absent', () => {
+test('renderMessages shows no protocol icon when node protocol is absent', () => {
const nodeContext = {
shortName: 'SRC',
longName: 'Source',
@@ -750,7 +752,8 @@ test('renderMessages prefixes meshtastic icon when node protocol is absent', ()
(short, role) => `${short}`,
nodeContext,
);
- assert.ok(html.includes('meshtastic.svg'), 'absent node protocol chat entry should show meshtastic icon');
+ assert.ok(!html.includes('meshtastic.svg'), 'absent node protocol chat entry should show no meshtastic icon');
+ assert.ok(!html.includes('meshcore.svg'), 'absent node protocol chat entry should show no meshcore icon');
});
test('renderMessages omits meshtastic icon for meshcore node protocol', () => {
diff --git a/web/public/assets/js/app/__tests__/node-rendering.test.js b/web/public/assets/js/app/__tests__/node-rendering.test.js
index 97df3a5..5925d6d 100644
--- a/web/public/assets/js/app/__tests__/node-rendering.test.js
+++ b/web/public/assets/js/app/__tests__/node-rendering.test.js
@@ -122,14 +122,21 @@ test('renderNodeLongNameLink renders anchor when identifier is present', () => {
assert.ok(html.includes('Alice'), 'long name should appear');
});
-test('renderNodeLongNameLink renders meshtastic icon for null protocol', () => {
+test('renderNodeLongNameLink shows no icon for null protocol', () => {
const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: null });
- assert.ok(html.includes('meshtastic.svg'), 'meshtastic icon should be shown for null protocol');
+ assert.ok(!html.includes('meshtastic.svg'), 'no icon when protocol is null');
+ assert.ok(!html.includes('meshcore.svg'), 'no icon when protocol is null');
});
-test('renderNodeLongNameLink renders meshtastic icon when protocol is absent', () => {
+test('renderNodeLongNameLink shows no icon when protocol is absent', () => {
const html = renderNodeLongNameLink('Alice', '!aabbccdd');
- assert.ok(html.includes('meshtastic.svg'));
+ assert.ok(!html.includes('meshtastic.svg'), 'no icon when protocol is absent');
+ assert.ok(!html.includes('meshcore.svg'), 'no icon when protocol is absent');
+});
+
+test('renderNodeLongNameLink renders meshtastic icon for explicit meshtastic protocol', () => {
+ const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: 'meshtastic' });
+ assert.ok(html.includes('meshtastic.svg'), 'meshtastic icon shown for explicit meshtastic protocol');
});
test('renderNodeLongNameLink uses meshcore icon for meshcore protocol', () => {
@@ -138,6 +145,21 @@ test('renderNodeLongNameLink uses meshcore icon for meshcore protocol', () => {
assert.ok(html.includes('meshcore.svg'), 'meshcore icon should be shown');
});
+test('renderNodeLongNameLink renders meshcore icon for meshcore protocol', () => {
+ const html = renderNodeLongNameLink('Eve', '!aabbccdd', { protocol: 'meshcore' });
+ assert.ok(html.includes('meshcore.svg'), 'meshcore icon should be shown for meshcore protocol');
+});
+
+test('renderNodeLongNameLink omits meshcore icon for meshtastic protocol', () => {
+ const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: 'meshtastic' });
+ assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon for meshtastic protocol');
+});
+
+test('renderNodeLongNameLink omits meshcore icon for null protocol', () => {
+ const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: null });
+ assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon for null protocol');
+});
+
test('renderNodeLongNameLink renders plain text when identifier is null', () => {
const html = renderNodeLongNameLink('Alice', null);
assert.ok(!html.includes(' {
- assert.equal(isMeshtasticProtocol(null), true);
-});
-
-test('isMeshtasticProtocol — undefined is Meshtastic (default)', () => {
- assert.equal(isMeshtasticProtocol(undefined), true);
-});
-
-test('isMeshtasticProtocol — empty string is Meshtastic', () => {
- assert.equal(isMeshtasticProtocol(''), true);
-});
-
-test('isMeshtasticProtocol — whitespace-only string is Meshtastic', () => {
- assert.equal(isMeshtasticProtocol(' '), true);
-});
+// ---------------------------------------------------------------------------
+// isMeshtasticProtocol — only matches the explicit string "meshtastic"
+// ---------------------------------------------------------------------------
test('isMeshtasticProtocol — "meshtastic" is Meshtastic', () => {
assert.equal(isMeshtasticProtocol('meshtastic'), true);
});
+test('isMeshtasticProtocol — null is not Meshtastic (no default)', () => {
+ assert.equal(isMeshtasticProtocol(null), false);
+});
+
+test('isMeshtasticProtocol — undefined is not Meshtastic (no default)', () => {
+ assert.equal(isMeshtasticProtocol(undefined), false);
+});
+
+test('isMeshtasticProtocol — empty string is not Meshtastic', () => {
+ assert.equal(isMeshtasticProtocol(''), false);
+});
+
+test('isMeshtasticProtocol — whitespace-only string is not Meshtastic', () => {
+ assert.equal(isMeshtasticProtocol(' '), false);
+});
+
test('isMeshtasticProtocol — "meshcore" is not Meshtastic', () => {
assert.equal(isMeshtasticProtocol('meshcore'), false);
});
@@ -107,26 +111,23 @@ test('MESHCORE_ICON_SRC is referenced by meshcoreIconHtml', () => {
// protocolIconPrefixHtml
// ---------------------------------------------------------------------------
-test('protocolIconPrefixHtml — null yields meshtastic icon prefix', () => {
- const result = protocolIconPrefixHtml(null);
- assert.ok(result.includes('meshtastic.svg'), 'null should produce the meshtastic icon');
- assert.ok(result.endsWith(' '), 'prefix must end with a trailing space');
+test('protocolIconPrefixHtml — null yields empty string (no default)', () => {
+ assert.equal(protocolIconPrefixHtml(null), '');
});
-test('protocolIconPrefixHtml — undefined yields meshtastic icon prefix', () => {
- const result = protocolIconPrefixHtml(undefined);
- assert.ok(result.includes('meshtastic.svg'), 'undefined should produce the meshtastic icon');
+test('protocolIconPrefixHtml — undefined yields empty string (no default)', () => {
+ assert.equal(protocolIconPrefixHtml(undefined), '');
});
-test('protocolIconPrefixHtml — empty string yields meshtastic icon prefix', () => {
- const result = protocolIconPrefixHtml('');
- assert.ok(result.includes('meshtastic.svg'), 'empty string should produce the meshtastic icon');
+test('protocolIconPrefixHtml — empty string yields empty string', () => {
+ assert.equal(protocolIconPrefixHtml(''), '');
});
test('protocolIconPrefixHtml — "meshtastic" yields meshtastic icon prefix', () => {
const result = protocolIconPrefixHtml('meshtastic');
assert.ok(result.includes('meshtastic.svg'), '"meshtastic" should produce the meshtastic icon');
assert.ok(!result.includes('meshcore.svg'), '"meshtastic" must not produce the meshcore icon');
+ assert.ok(result.endsWith(' '), 'prefix must end with a trailing space');
});
test('protocolIconPrefixHtml — "meshcore" yields meshcore icon prefix', () => {
diff --git a/web/public/assets/js/app/__tests__/role-helpers.test.js b/web/public/assets/js/app/__tests__/role-helpers.test.js
index 0edc124..43ce273 100644
--- a/web/public/assets/js/app/__tests__/role-helpers.test.js
+++ b/web/public/assets/js/app/__tests__/role-helpers.test.js
@@ -22,7 +22,11 @@ import {
getRoleKey,
getRoleRenderPriority,
getRoleColors,
+ getRoleTextColor,
meshcoreRoleColors,
+ meshcoreRoleTextColors,
+ meshcoreRoleRenderOrder,
+ meshtasticRoleRenderOrder,
roleColors,
normalizeRole,
translateRoleId,
@@ -52,10 +56,53 @@ test('role key and color lookups prefer known values with uppercase fallback', (
});
test('render priority uses canonical role keys and defaults to zero for unknowns', () => {
+ // translateRoleId(2) → 'ROUTER', so both should resolve to the same priority
assert.equal(getRoleRenderPriority('ROUTER'), getRoleRenderPriority(2));
assert.equal(getRoleRenderPriority('custom-role'), 0);
});
+test('render priority is protocol-aware for shared roles', () => {
+ // SENSOR: meshtastic=2, meshcore=3
+ assert.equal(getRoleRenderPriority('SENSOR', 'meshtastic'), 2);
+ assert.equal(getRoleRenderPriority('SENSOR', 'meshcore'), 3);
+ assert.ok(getRoleRenderPriority('SENSOR', 'meshcore') > getRoleRenderPriority('SENSOR', 'meshtastic'));
+ // REPEATER: meshtastic=11, meshcore=12
+ assert.equal(getRoleRenderPriority('REPEATER', 'meshtastic'), 11);
+ assert.equal(getRoleRenderPriority('REPEATER', 'meshcore'), 12);
+ assert.ok(getRoleRenderPriority('REPEATER', 'meshcore') > getRoleRenderPriority('REPEATER', 'meshtastic'));
+});
+
+test('render priority meshcore-exclusive roles have defined priorities', () => {
+ assert.equal(getRoleRenderPriority('COMPANION', 'meshcore'), 7);
+ assert.equal(getRoleRenderPriority('ROOM_SERVER', 'meshcore'), 9);
+});
+
+test('render priority respects the full bottom-to-top order', () => {
+ const order = [
+ ['CLIENT_HIDDEN', null],
+ ['SENSOR', 'meshtastic'],
+ ['SENSOR', 'meshcore'],
+ ['TRACKER', null],
+ ['CLIENT_MUTE', null],
+ ['CLIENT', null],
+ ['COMPANION', 'meshcore'],
+ ['CLIENT_BASE', null],
+ ['ROOM_SERVER', 'meshcore'],
+ ['ROUTER_LATE', null],
+ ['REPEATER', 'meshtastic'],
+ ['REPEATER', 'meshcore'],
+ ['ROUTER', null],
+ ['LOST_AND_FOUND', null],
+ ];
+ for (let i = 1; i < order.length; i++) {
+ const [roleA, protoA] = order[i - 1];
+ const [roleB, protoB] = order[i];
+ const pA = getRoleRenderPriority(roleA, protoA);
+ const pB = getRoleRenderPriority(roleB, protoB);
+ assert.ok(pA < pB, `Expected ${roleA}/${protoA} (${pA}) < ${roleB}/${protoB} (${pB})`);
+ }
+});
+
test('getRoleColors returns Meshtastic palette for null/undefined/meshtastic', () => {
assert.equal(getRoleColors(null), roleColors);
assert.equal(getRoleColors(undefined), roleColors);
@@ -70,3 +117,34 @@ test('getRoleColors returns MeshCore palette for meshcore protocol', () => {
test('getRoleColors returns Meshtastic palette for unknown protocols', () => {
assert.equal(getRoleColors('reticulum'), roleColors);
});
+
+test('getRoleColor uses meshcore palette when protocol is meshcore', () => {
+ assert.equal(getRoleColor('COMPANION', 'meshcore'), meshcoreRoleColors.COMPANION);
+ assert.equal(getRoleColor('REPEATER', 'meshcore'), meshcoreRoleColors.REPEATER);
+ assert.equal(getRoleColor('ROOM_SERVER', 'meshcore'), meshcoreRoleColors.ROOM_SERVER);
+ assert.equal(getRoleColor('SENSOR', 'meshcore'), meshcoreRoleColors.SENSOR);
+});
+
+test('getRoleColor uses meshtastic palette when protocol is null', () => {
+ assert.equal(getRoleColor('ROUTER', null), roleColors.ROUTER);
+ assert.equal(getRoleColor('CLIENT', null), roleColors.CLIENT);
+});
+
+test('getRoleColor falls back to CLIENT color for unknown meshcore role', () => {
+ assert.equal(getRoleColor('UNKNOWN_ROLE', 'meshcore'), roleColors.CLIENT);
+});
+
+test('getRoleTextColor returns light grey for meshcore COMPANION', () => {
+ assert.equal(getRoleTextColor('COMPANION', 'meshcore'), meshcoreRoleTextColors.COMPANION);
+});
+
+test('getRoleTextColor returns null for meshcore roles without override', () => {
+ assert.equal(getRoleTextColor('REPEATER', 'meshcore'), null);
+ assert.equal(getRoleTextColor('ROOM_SERVER', 'meshcore'), null);
+ assert.equal(getRoleTextColor('SENSOR', 'meshcore'), null);
+});
+
+test('getRoleTextColor returns null for meshtastic roles', () => {
+ assert.equal(getRoleTextColor('CLIENT', 'meshtastic'), null);
+ assert.equal(getRoleTextColor('ROUTER', null), null);
+});
diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js
index 3a734fc..7008277 100644
--- a/web/public/assets/js/app/main.js
+++ b/web/public/assets/js/app/main.js
@@ -95,9 +95,10 @@ import {
getRoleColor,
getRoleKey,
getRoleRenderPriority,
+ getRoleTextColor,
+ meshcoreRoleColors,
normalizeRole,
roleColors,
- roleRenderOrder,
} from './role-helpers.js';
import {
isMeshtasticProtocol,
@@ -835,8 +836,63 @@ export function initializeApp(config) {
syncInfoOverlayHost();
+ /** @type {Set} Active compound role-filter keys, each ``":"``. */
const activeRoleFilters = new Set();
+ /** @type {Map} Compound key → legend button element. */
const legendRoleButtons = new Map();
+ /** @type {Set} Protocols hidden by the user via legend toggles. */
+ const hiddenProtocols = new Set();
+ const legendProtocolButtons = new Map();
+
+ /**
+ * Wrap a legend button click handler so it always calls
+ * ``preventDefault`` and ``stopPropagation`` before running the body.
+ *
+ * Centralising this prevents the two-line boilerplate from repeating in
+ * every legend button handler, reducing token-level duplication.
+ *
+ * @param {function(Event): void} fn Handler body.
+ * @returns {function(Event): void} Full click listener.
+ */
+ function legendClickHandler(fn) {
+ return (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ fn(event);
+ };
+ }
+
+ /**
+ * Canonical protocol token for use in compound filter keys.
+ *
+ * Collapses null/absent/unknown protocol values to ``'meshtastic'`` so that
+ * pre-protocol legacy records land in the Meshtastic filter bucket.
+ *
+ * @param {string|null|undefined} protocol Raw protocol value.
+ * @returns {'meshtastic'|'meshcore'} Normalised protocol token.
+ */
+ function normalizeFilterProtocol(protocol) {
+ return isMeshcoreProtocol(protocol) ? 'meshcore' : 'meshtastic';
+ }
+
+ /**
+ * Build a compound filter key that encodes both protocol and role.
+ *
+ * Using compound keys avoids collisions between role names that appear in
+ * both Meshtastic and MeshCore (e.g. ``SENSOR``, ``REPEATER``). The filter
+ * set stores these keys so that clicking the MeshCore SENSOR button only
+ * includes MeshCore SENSOR nodes, not Meshtastic ones.
+ *
+ * @param {*} role Raw role value from the API.
+ * @param {string|null|undefined} protocol Protocol string from the API.
+ * @returns {string} Compound key in the form ``":"``.
+ */
+ function makeRoleFilterKey(role, protocol) {
+ return `${normalizeFilterProtocol(protocol)}:${getRoleKey(role)}`;
+ }
+
+ /** @type {Readonly>} Display names for protocol tokens. */
+ const PROTOCOL_DISPLAY_NAMES = Object.freeze({ meshtastic: 'Meshtastic', meshcore: 'MeshCore' });
/**
* Lazily create the floating map status element used for progress messages.
@@ -1366,31 +1422,44 @@ export function initializeApp(config) {
* @returns {void}
*/
/**
- * Build a Meshtastic protocol icon ``
`` element via DOM APIs.
+ * Build a protocol icon ``
`` element via DOM APIs.
*
- * Used wherever an icon node must be appended rather than injected via
- * ``innerHTML``. Mirrors the attribute set used by {@link meshtasticIconHtml}
- * so the rendered output is identical.
+ * Shared implementation used by {@link buildMeshtasticIconImg} and
+ * {@link buildMeshcoreIconImg}. Mirrors the attribute set produced by
+ * the HTML-string helpers in ``protocol-helpers.js`` so the rendered
+ * output is identical regardless of insertion method.
*
+ * @param {string} src Absolute path to the SVG asset.
+ * @param {string} variantClass BEM modifier class, e.g. ``protocol-icon--meshtastic``.
* @returns {HTMLImageElement} Icon element ready to append.
*/
- function buildMeshtasticIconImg() {
+ function buildProtocolIconImg(src, variantClass) {
const img = document.createElement('img');
- img.setAttribute('src', MESHTASTIC_ICON_SRC);
+ img.setAttribute('src', src);
img.setAttribute('alt', '');
img.setAttribute('width', '12');
img.setAttribute('height', '12');
img.setAttribute('aria-hidden', 'true');
img.setAttribute('loading', 'lazy');
img.setAttribute('decoding', 'async');
- img.className = 'protocol-icon protocol-icon--meshtastic';
+ img.className = `protocol-icon ${variantClass}`;
return img;
}
+ /** @returns {HTMLImageElement} Meshtastic protocol icon element. */
+ function buildMeshtasticIconImg() {
+ return buildProtocolIconImg(MESHTASTIC_ICON_SRC, 'protocol-icon--meshtastic');
+ }
+
+ /** @returns {HTMLImageElement} MeshCore protocol icon element. */
+ function buildMeshcoreIconImg() {
+ return buildProtocolIconImg(MESHCORE_ICON_SRC, 'protocol-icon--meshcore');
+ }
+
function updateNeighborLinesToggleState() {
if (!neighborLinesToggleButton) return;
const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines';
- neighborLinesToggleButton.replaceChildren(buildMeshtasticIconImg(), document.createTextNode(` ${label}`));
+ neighborLinesToggleButton.textContent = label;
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false');
neighborLinesToggleButton.setAttribute('aria-label', label);
}
@@ -1422,7 +1491,7 @@ export function initializeApp(config) {
function updateTraceLinesToggleState() {
if (!traceLinesToggleButton) return;
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
- traceLinesToggleButton.replaceChildren(buildMeshtasticIconImg(), document.createTextNode(` ${label}`));
+ traceLinesToggleButton.textContent = label;
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
traceLinesToggleButton.setAttribute('aria-label', label);
}
@@ -1453,13 +1522,21 @@ export function initializeApp(config) {
*/
function updateLegendRoleFiltersUI() {
const hasFilters = activeRoleFilters.size > 0;
- legendRoleButtons.forEach((button, role) => {
+ // legendRoleButtons is keyed by compound key ("protocol:roleKey")
+ legendRoleButtons.forEach((button, compoundKey) => {
if (!button) return;
- const isActive = activeRoleFilters.has(role);
+ const isActive = activeRoleFilters.has(compoundKey);
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
+ legendProtocolButtons.forEach((button, protocol) => {
+ if (!button) return;
+ const isHidden = hiddenProtocols.has(protocol);
+ const displayName = PROTOCOL_DISPLAY_NAMES[protocol] ?? protocol;
+ button.setAttribute('aria-pressed', isHidden ? 'true' : 'false');
+ button.textContent = isHidden ? `Show ${displayName}` : `Hide ${displayName}`;
+ });
if (legendContainer) {
- if (hasFilters) {
+ if (hasFilters || hiddenProtocols.size > 0) {
legendContainer.setAttribute('data-has-active-filters', 'true');
} else {
legendContainer.removeAttribute('data-has-active-filters');
@@ -1469,22 +1546,69 @@ export function initializeApp(config) {
}
/**
- * Toggle the visibility filter for a given role.
+ * Toggle the visibility filter for a role+protocol combination.
*
- * @param {string} role Role identifier.
+ * @param {string} compoundKey Compound key in the form ``":"``.
* @returns {void}
*/
- function toggleRoleFilter(role) {
- if (!role) return;
- if (activeRoleFilters.has(role)) {
- activeRoleFilters.delete(role);
+ function toggleRoleFilter(compoundKey) {
+ if (!compoundKey) return;
+ if (activeRoleFilters.has(compoundKey)) {
+ activeRoleFilters.delete(compoundKey);
} else {
- activeRoleFilters.add(role);
+ activeRoleFilters.add(compoundKey);
}
updateLegendRoleFiltersUI();
applyFilter();
}
+ /**
+ * Build role filter buttons for a given palette and append them to a column.
+ *
+ * Each button is keyed by a compound ``":"`` string so
+ * that roles sharing a name across protocols (e.g. ``SENSOR``, ``REPEATER``)
+ * produce independent buttons without colliding in {@link legendRoleButtons}.
+ *
+ * @param {HTMLElement} colEl Column container element.
+ * @param {Record} palette Role→colour map to render.
+ * @param {'meshtastic'|'meshcore'} protocol Protocol token for this column.
+ * @returns {void}
+ */
+ function buildRoleButtons(colEl, palette, protocol) {
+ for (const [role, color] of Object.entries(palette)) {
+ if (!CHAT_ENABLED && role === 'CLIENT_HIDDEN') continue;
+ const compoundKey = makeRoleFilterKey(role, protocol);
+ const item = document.createElement('button');
+ item.className = 'legend-item';
+ colEl.appendChild(item);
+ item.type = 'button';
+ item.setAttribute('aria-pressed', 'false');
+ item.dataset.role = role;
+ item.dataset.protocol = protocol;
+ const swatch = document.createElement('span');
+ swatch.className = 'legend-swatch';
+ item.appendChild(swatch);
+ swatch.style.background = color;
+ swatch.setAttribute('aria-hidden', 'true');
+ const label = document.createElement('span');
+ label.className = 'legend-label';
+ item.appendChild(label);
+ label.textContent = role;
+ item.addEventListener('click', legendClickHandler(event => {
+ const exclusive = event.metaKey || event.ctrlKey;
+ if (exclusive) {
+ activeRoleFilters.clear();
+ activeRoleFilters.add(compoundKey);
+ updateLegendRoleFiltersUI();
+ applyFilter();
+ } else {
+ toggleRoleFilter(compoundKey);
+ }
+ }));
+ legendRoleButtons.set(compoundKey, item);
+ }
+ }
+
if (map && hasLeaflet) {
const legend = L.control({ position: 'bottomright' });
/**
@@ -1503,66 +1627,81 @@ export function initializeApp(config) {
const title = L.DomUtil.create('span', 'legend-title', header);
title.textContent = 'Legend';
- const itemsContainer = L.DomUtil.create('div', 'legend-items', div);
- legendRoleButtons.clear();
- for (const [role, color] of Object.entries(roleColors)) {
- if (!CHAT_ENABLED && role === 'CLIENT_HIDDEN') continue;
- const item = L.DomUtil.create('button', 'legend-item', itemsContainer);
- item.type = 'button';
- item.setAttribute('aria-pressed', 'false');
- item.dataset.role = role;
- item.appendChild(buildMeshtasticIconImg());
- const swatch = L.DomUtil.create('span', 'legend-swatch', item);
- swatch.style.background = color;
- swatch.setAttribute('aria-hidden', 'true');
- const label = L.DomUtil.create('span', 'legend-label', item);
- label.textContent = role;
- item.addEventListener('click', event => {
- event.preventDefault();
- event.stopPropagation();
- const exclusive = event.metaKey || event.ctrlKey;
- if (exclusive) {
- activeRoleFilters.clear();
- activeRoleFilters.add(role);
- updateLegendRoleFiltersUI();
- applyFilter();
- } else {
- toggleRoleFilter(role);
- }
- });
- legendRoleButtons.set(role, item);
- }
- updateLegendRoleFiltersUI();
+ const itemsContainer = L.DomUtil.create('div', 'legend-items legend-items--columns', div);
- const toggle = L.DomUtil.create('div', 'legend-toggle', div);
- neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', toggle);
+ // --- MeshCore column (left) ---
+ const meshcoreCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
+ const meshcoreColHeader = L.DomUtil.create('div', 'legend-column-header', meshcoreCol);
+ meshcoreColHeader.appendChild(buildMeshcoreIconImg());
+ const meshcoreColTitle = document.createElement('span');
+ meshcoreColTitle.textContent = 'MeshCore';
+ meshcoreColHeader.appendChild(meshcoreColTitle);
+
+ // --- Meshtastic column (right) ---
+ const meshtasticCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
+ const meshtasticColHeader = L.DomUtil.create('div', 'legend-column-header', meshtasticCol);
+ meshtasticColHeader.appendChild(buildMeshtasticIconImg());
+ const meshtasticColTitle = document.createElement('span');
+ meshtasticColTitle.textContent = 'Meshtastic';
+ meshtasticColHeader.appendChild(meshtasticColTitle);
+
+ legendRoleButtons.clear();
+ buildRoleButtons(meshcoreCol, meshcoreRoleColors, 'meshcore');
+ buildRoleButtons(meshtasticCol, roleColors, 'meshtastic');
+
+ // --- Protocol hide toggles — one per column footer ---
+ legendProtocolButtons.clear();
+ const protocolColDefs = [
+ { protocol: 'meshcore', col: meshcoreCol },
+ { protocol: 'meshtastic', col: meshtasticCol },
+ ];
+ for (const { protocol, col } of protocolColDefs) {
+ const displayName = PROTOCOL_DISPLAY_NAMES[protocol] ?? protocol;
+ const btn = L.DomUtil.create('button', 'legend-item legend-protocol-toggle', col);
+ btn.type = 'button';
+ btn.setAttribute('aria-pressed', 'false');
+ btn.textContent = `Hide ${displayName}`;
+ btn.addEventListener('click', legendClickHandler(() => {
+ if (hiddenProtocols.has(protocol)) {
+ hiddenProtocols.delete(protocol);
+ } else {
+ hiddenProtocols.add(protocol);
+ }
+ updateLegendRoleFiltersUI();
+ applyFilter();
+ }));
+ legendProtocolButtons.set(protocol, btn);
+ }
+
+ // --- Line toggles in the Meshtastic column ---
+ neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', meshtasticCol);
neighborLinesToggleButton.type = 'button';
- neighborLinesToggleButton.addEventListener('click', event => {
- event.preventDefault();
- event.stopPropagation();
+ neighborLinesToggleButton.addEventListener('click', legendClickHandler(() => {
setNeighborLinesVisibility(!neighborLinesVisible);
- });
+ }));
updateNeighborLinesToggleState();
- traceLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-traces', toggle);
+ traceLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-traces', meshtasticCol);
traceLinesToggleButton.type = 'button';
- traceLinesToggleButton.addEventListener('click', event => {
- event.preventDefault();
- event.stopPropagation();
+ traceLinesToggleButton.addEventListener('click', legendClickHandler(() => {
setTraceLinesVisibility(!traceLinesVisible);
- });
+ }));
updateTraceLinesToggleState();
+ updateLegendRoleFiltersUI();
+
+ // --- Clear filters — full-width below the two columns ---
+ const toggle = L.DomUtil.create('div', 'legend-toggle', div);
+
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
resetButton.type = 'button';
resetButton.textContent = 'Clear filters';
- resetButton.addEventListener('click', event => {
- event.preventDefault();
- event.stopPropagation();
+ resetButton.addEventListener('click', legendClickHandler(() => {
activeRoleFilters.clear();
+ hiddenProtocols.clear();
updateLegendRoleFiltersUI();
applyFilter();
- });
+ }));
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
@@ -1586,11 +1725,9 @@ export function initializeApp(config) {
button.setAttribute('aria-pressed', 'true');
button.setAttribute('aria-label', 'Hide map legend');
button.setAttribute('aria-controls', 'mapLegend');
- button.addEventListener('click', event => {
- event.preventDefault();
- event.stopPropagation();
+ button.addEventListener('click', legendClickHandler(() => {
setLegendVisibility(!legendVisible);
- });
+ }));
legendToggleButton = button;
updateLegendToggleState();
L.DomEvent.disableClickPropagation(container);
@@ -1848,8 +1985,11 @@ export function initializeApp(config) {
return `? `;
}
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, ' ');
- const color = getRoleColor(roleValue);
- return `${padded}`;
+ const protocol = nodeData?.protocol ?? null;
+ const color = getRoleColor(roleValue, protocol);
+ const textColor = getRoleTextColor(roleValue, protocol);
+ const styleAttr = textColor ? `background:${color};color:${textColor}` : `background:${color}`;
+ return `${padded}`;
}
const potatoMeshNamespace = globalThis.PotatoMesh || (globalThis.PotatoMesh = {});
@@ -2266,7 +2406,7 @@ export function initializeApp(config) {
}
}
const shortParts = [];
- const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName);
+ const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName, overlayInfo);
if (shortHtml) {
shortParts.push(shortHtml);
}
@@ -3852,8 +3992,10 @@ export function initializeApp(config) {
const loraFrequencyText = formatLoraFrequencyMHz(modemMetadata.loraFreq);
const loraFrequencyDisplay = loraFrequencyText ? escapeHtml(loraFrequencyText) : '';
const modemPresetDisplay = modemMetadata.modemPreset ? escapeHtml(modemMetadata.modemPreset) : '';
- const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id, { protocol: n.protocol });
+ const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id);
+ const protocolIconCell = protocolIconPrefixHtml(n.protocol);
tr.innerHTML = `
+ ${protocolIconCell} |
${n.node_id || ""} |
${renderShortHtml(n.short_name, n.role, n.long_name, n)} |
${longNameHtml} |
@@ -3933,7 +4075,7 @@ export function initializeApp(config) {
? buildTraceSegments(allTraces, nodes, {
limitDistance: LIMIT_DISTANCE,
maxDistanceKm: MAX_DISTANCE_KM,
- colorForNode: node => getRoleColor(node.role)
+ colorForNode: node => getRoleColor(node.role, node.protocol)
})
: [];
@@ -3973,7 +4115,7 @@ export function initializeApp(config) {
if (LIMIT_DISTANCE && sourceNode.distance_km != null && sourceNode.distance_km > MAX_DISTANCE_KM) continue;
if (LIMIT_DISTANCE && targetNode.distance_km != null && targetNode.distance_km > MAX_DISTANCE_KM) continue;
- const priority = getRoleRenderPriority(sourceNode.role);
+ const priority = getRoleRenderPriority(sourceNode.role, sourceNode.protocol);
const rxTimeRaw = entry.rx_time;
let rxTime = 0;
if (typeof rxTimeRaw === 'number' && Number.isFinite(rxTimeRaw)) {
@@ -3991,7 +4133,7 @@ export function initializeApp(config) {
neighborSegments.push({
latlngs: [[srcLat, srcLon], [tgtLat, tgtLon]],
- color: getRoleColor(sourceNode.role),
+ color: getRoleColor(sourceNode.role, sourceNode.protocol),
priority,
rxTime,
sourceId,
@@ -4123,8 +4265,8 @@ export function initializeApp(config) {
const nodesByRenderOrder = nodes
.map((node, index) => ({ node, index }))
.sort((a, b) => {
- const orderA = getRoleRenderPriority(a.node && a.node.role);
- const orderB = getRoleRenderPriority(b.node && b.node.role);
+ const orderA = getRoleRenderPriority(a.node && a.node.role, a.node && a.node.protocol);
+ const orderB = getRoleRenderPriority(b.node && b.node.role, b.node && b.node.protocol);
if (orderA !== orderB) return orderA - orderB;
return a.index - b.index;
})
@@ -4137,7 +4279,7 @@ export function initializeApp(config) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (LIMIT_DISTANCE && n.distance_km != null && n.distance_km > MAX_DISTANCE_KM) continue;
- const color = getRoleColor(n.role);
+ const color = getRoleColor(n.role, n.protocol);
const marker = L.circleMarker([lat, lon], {
radius: 9,
color: '#000',
@@ -4222,13 +4364,36 @@ export function initializeApp(config) {
/**
* Test whether a node matches the active role filters.
*
+ * Filters use compound ``":"`` keys so that shared role
+ * names (e.g. ``SENSOR``, ``REPEATER``) can be toggled independently per
+ * protocol. Nodes whose protocol is null/absent are treated as Meshtastic
+ * (via {@link normalizeFilterProtocol}) to keep legacy records visible when
+ * the Meshtastic SENSOR filter is active.
+ *
* @param {Object} node Node payload.
* @returns {boolean} True when the node should be visible.
*/
function matchesRoleFilter(node) {
if (!activeRoleFilters.size) return true;
- const roleKey = getRoleKey(node && node.role);
- return activeRoleFilters.has(roleKey);
+ const compoundKey = makeRoleFilterKey(node && node.role, node && node.protocol);
+ return activeRoleFilters.has(compoundKey);
+ }
+
+ /**
+ * Check whether a node passes the active protocol visibility filters.
+ *
+ * Nodes with a null/absent protocol are always shown — hiding
+ * ``'meshtastic'`` hides only nodes that explicitly carry that protocol
+ * value. Pre-protocol legacy records remain visible regardless.
+ *
+ * @param {Object} node Node payload.
+ * @returns {boolean} True when the node should be visible.
+ */
+ function matchesProtocolFilter(node) {
+ if (!hiddenProtocols.size) return true;
+ const protocol = (node && node.protocol) || null;
+ if (protocol && hiddenProtocols.has(protocol)) return false;
+ return true;
}
/**
@@ -4256,7 +4421,7 @@ export function initializeApp(config) {
// Text and role filters apply only to the node table and map; the chat log
// always receives the full node collection so reply-thread lookups succeed
// even for nodes that are currently hidden by the active filter.
- const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n));
+ const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n) && matchesProtocolFilter(n));
const sortedNodes = sortNodes(filteredNodes);
const nowSec = Date.now()/1000;
renderTable(sortedNodes, nowSec);
@@ -4475,6 +4640,20 @@ export function initializeApp(config) {
createAnnouncementEntry,
createMessageChatEntry,
buildDisplayContext,
+ makeRoleFilterKey,
+ normalizeFilterProtocol,
+ matchesRoleFilter,
+ matchesProtocolFilter,
+ buildProtocolIconImg,
+ buildMeshtasticIconImg,
+ buildMeshcoreIconImg,
+ buildRoleButtons,
+ updateLegendRoleFiltersUI,
+ legendClickHandler,
+ activeRoleFilters,
+ hiddenProtocols,
+ legendRoleButtons,
+ legendProtocolButtons,
},
};
}
diff --git a/web/public/assets/js/app/node-details.js b/web/public/assets/js/app/node-details.js
index a4fc7d0..894e909 100644
--- a/web/public/assets/js/app/node-details.js
+++ b/web/public/assets/js/app/node-details.js
@@ -178,6 +178,7 @@ function mergeNodeFields(target, record) {
assignString(target, 'shortName', extractString(record, ['shortName', 'short_name']));
assignString(target, 'longName', extractString(record, ['longName', 'long_name']));
assignString(target, 'role', extractString(record, ['role']));
+ assignString(target, 'protocol', extractString(record, ['protocol']));
assignString(target, 'hwModel', extractString(record, ['hwModel', 'hw_model']));
mergeModemMetadata(target, record);
assignNumber(target, 'snr', extractNumber(record, ['snr']));
diff --git a/web/public/assets/js/app/node-rendering.js b/web/public/assets/js/app/node-rendering.js
index bbb66a8..2894d04 100644
--- a/web/public/assets/js/app/node-rendering.js
+++ b/web/public/assets/js/app/node-rendering.js
@@ -79,9 +79,9 @@ export function canonicalNodeIdentifier(identifier) {
/**
* Render a linked long name pointing to the node detail view.
*
- * When ``protocol`` is Meshtastic (including null/empty per
- * {@link module:protocol-helpers~isMeshtasticProtocol}) or ``"meshcore"``, the
- * matching protocol icon is prepended. An anchor element is only emitted when
+ * When ``protocol`` is a known value (``"meshtastic"`` or ``"meshcore"``),
+ * the matching protocol icon is prepended. Absent or unknown protocol strings
+ * produce no icon prefix. An anchor element is only emitted when
* ``identifier`` resolves to a non-null detail path.
*
* @param {string|null} longName Display name.
diff --git a/web/public/assets/js/app/protocol-helpers.js b/web/public/assets/js/app/protocol-helpers.js
index 355ab6e..7b4c134 100644
--- a/web/public/assets/js/app/protocol-helpers.js
+++ b/web/public/assets/js/app/protocol-helpers.js
@@ -21,11 +21,10 @@ export const MESHTASTIC_ICON_SRC = '/assets/img/meshtastic.svg';
export const MESHCORE_ICON_SRC = '/assets/img/meshcore.svg';
/**
- * Return true when the protocol value represents Meshtastic or is absent.
+ * Return true when the protocol value is explicitly ``"meshtastic"``.
*
- * A null/undefined/empty string is treated as Meshtastic because the backend
- * defaults all records to ``"meshtastic"`` and pre-existing records that
- * predate the protocol column carry an implicit Meshtastic origin.
+ * Absent, null, or empty values return ``false`` — no default is applied.
+ * An icon is only shown when the protocol is positively known.
*
* Comparison is case-sensitive: only the lowercase value ``"meshtastic"``
* matches — mixed-case strings such as ``"Meshtastic"`` return ``false``.
@@ -33,12 +32,11 @@ export const MESHCORE_ICON_SRC = '/assets/img/meshcore.svg';
* intentional.
*
* @param {string|null|undefined} protocol Protocol string from the API.
- * @returns {boolean} Whether the protocol is (or defaults to) Meshtastic.
+ * @returns {boolean} Whether the protocol is explicitly Meshtastic.
*/
export function isMeshtasticProtocol(protocol) {
- if (protocol == null) return true;
- const trimmed = String(protocol).trim();
- return trimmed.length === 0 || trimmed === 'meshtastic';
+ if (protocol == null) return false;
+ return String(protocol).trim() === 'meshtastic';
}
/**
@@ -83,10 +81,10 @@ export function meshcoreIconHtml() {
/**
* Build an HTML prefix (protocol icon plus a trailing space) for inline UI.
*
- * Meshtastic — including null, undefined, empty, or whitespace-only values per
- * {@link isMeshtasticProtocol} — uses the Meshtastic icon. The literal
- * ``"meshcore"`` uses the MeshCore icon. Any other protocol string yields an
- * empty prefix (same as the pre-MeshCore behaviour for unknown stacks).
+ * Returns the matching icon only when the protocol is positively known:
+ * ``"meshtastic"`` → Meshtastic icon, ``"meshcore"`` → MeshCore icon.
+ * Absent, null, or unrecognised protocol strings yield an empty string —
+ * no default icon is assumed.
*
* @param {string|null|undefined} protocol Protocol string from the API.
* @returns {string} HTML fragment safe to concatenate before visible text.
diff --git a/web/public/assets/js/app/role-helpers.js b/web/public/assets/js/app/role-helpers.js
index 5e4614b..af0032b 100644
--- a/web/public/assets/js/app/role-helpers.js
+++ b/web/public/assets/js/app/role-helpers.js
@@ -14,6 +14,8 @@
* limitations under the License.
*/
+import { isMeshcoreProtocol } from './protocol-helpers.js';
+
/**
* Mapping of numeric Meshtastic role identifiers to their canonical names.
*
@@ -81,6 +83,33 @@ export const meshcoreRoleColors = Object.freeze({
COMPANION: '#1A5498',
});
+/**
+ * MeshCore role text colour overrides — only populated for roles whose
+ * background is dark enough that the default (near-black) text becomes
+ * illegible. Roles absent from this map inherit the page default.
+ *
+ * @type {Readonly>}
+ */
+export const meshcoreRoleTextColors = Object.freeze({
+ COMPANION: '#e0e0e0',
+});
+
+/**
+ * Return the foreground text colour for a role badge, or ``null`` when the
+ * page default is acceptable.
+ *
+ * @param {*} role Raw role value from the API.
+ * @param {string|null|undefined} [protocol] Protocol string from the API.
+ * @returns {string|null} CSS colour string, or ``null`` to inherit.
+ */
+export function getRoleTextColor(role, protocol = null) {
+ if (isMeshcoreProtocol(protocol)) {
+ const key = getRoleKey(role);
+ return meshcoreRoleTextColors[key] ?? null;
+ }
+ return null;
+}
+
/**
* Return the role colour palette appropriate for the given protocol.
*
@@ -92,25 +121,51 @@ export const meshcoreRoleColors = Object.freeze({
* @returns {Readonly>} Role colour map.
*/
export function getRoleColors(protocol) {
- if (protocol != null && String(protocol).trim() === 'meshcore') {
- return meshcoreRoleColors;
- }
- return roleColors;
+ return isMeshcoreProtocol(protocol) ? meshcoreRoleColors : roleColors;
}
-export const roleRenderOrder = Object.freeze({
+/**
+ * Meshtastic-specific render priority order for map marker stacking.
+ * Higher numbers render above lower ones (LOST_AND_FOUND on top).
+ *
+ * @type {Readonly>}
+ */
+export const meshtasticRoleRenderOrder = Object.freeze({
CLIENT_HIDDEN: 1,
SENSOR: 2,
- TRACKER: 3,
- CLIENT_MUTE: 4,
- CLIENT: 5,
- CLIENT_BASE: 6,
- REPEATER: 7,
- ROUTER_LATE: 8,
- ROUTER: 9,
- LOST_AND_FOUND: 10
+ TRACKER: 4,
+ CLIENT_MUTE: 5,
+ CLIENT: 6,
+ CLIENT_BASE: 8,
+ ROUTER_LATE: 10,
+ REPEATER: 11,
+ ROUTER: 13,
+ LOST_AND_FOUND: 14,
});
+/**
+ * MeshCore-specific render priority overrides. Only roles whose stacking
+ * order differs from the Meshtastic palette need to appear here — any role
+ * absent from this map falls through to {@link meshtasticRoleRenderOrder}.
+ *
+ * @type {Readonly>}
+ */
+export const meshcoreRoleRenderOrder = Object.freeze({
+ SENSOR: 3,
+ COMPANION: 7,
+ ROOM_SERVER: 9,
+ REPEATER: 12,
+});
+
+/**
+ * Backward-compatible alias kept for any code that still imports
+ * ``roleRenderOrder`` by name.
+ *
+ * @deprecated Use {@link meshtasticRoleRenderOrder} directly.
+ * @type {Readonly>}
+ */
+export const roleRenderOrder = meshtasticRoleRenderOrder;
+
/**
* Translate numeric identifiers or numeric strings into canonical role names.
*
@@ -156,22 +211,37 @@ export function getRoleKey(role) {
/**
* Determine the colour assigned to a role for legend badges.
*
+ * Pass the node's ``protocol`` field to select the correct palette: MeshCore
+ * roles are looked up in {@link meshcoreRoleColors}; everything else falls
+ * back to the Meshtastic {@link roleColors} palette.
+ *
* @param {*} role Raw role value.
+ * @param {string|null|undefined} [protocol] Protocol string from the API.
* @returns {string} CSS colour string.
*/
-export function getRoleColor(role) {
+export function getRoleColor(role, protocol = null) {
+ const colors = getRoleColors(protocol);
const key = getRoleKey(role);
- return roleColors[key] || roleColors.CLIENT || '#3388ff';
+ return colors[key] || roleColors.CLIENT || '#3388ff';
}
/**
* Determine the render priority that decides marker stacking order.
*
+ * MeshCore nodes use {@link meshcoreRoleRenderOrder} for roles that differ
+ * from Meshtastic; everything else falls back to
+ * {@link meshtasticRoleRenderOrder}.
+ *
* @param {*} role Raw role value.
+ * @param {string|null|undefined} [protocol] Protocol string from the API.
* @returns {number} Higher numbers render above lower ones.
*/
-export function getRoleRenderPriority(role) {
+export function getRoleRenderPriority(role, protocol = null) {
const key = getRoleKey(role);
- const priority = roleRenderOrder[key];
+ if (isMeshcoreProtocol(protocol)) {
+ const mc = meshcoreRoleRenderOrder[key];
+ if (typeof mc === 'number') return mc;
+ }
+ const priority = meshtasticRoleRenderOrder[key];
return typeof priority === 'number' ? priority : 0;
}
diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css
index 7f0d03b..d0122fa 100644
--- a/web/public/assets/styles/base.css
+++ b/web/public/assets/styles/base.css
@@ -1709,6 +1709,29 @@ input[type="radio"] {
gap: 2px;
}
+.legend-items--columns {
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.legend-column {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.legend-column-header {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-weight: 600;
+ font-size: 11px;
+ padding: 2px 6px 4px;
+ color: var(--fg);
+}
+
.legend-item {
display: flex;
align-items: center;
@@ -1735,8 +1758,16 @@ input[type="radio"] {
}
.legend-item[aria-pressed="true"] {
- border-color: rgba(0, 0, 0, 0.2);
- background: rgba(0, 0, 0, 0.08);
+ border-color: #3a7bd5;
+ background: rgba(58, 123, 213, 0.18);
+ color: #1a4a9e;
+ font-weight: 600;
+}
+
+.legend-protocol-toggle {
+ margin-top: 4px;
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
+ padding-top: 5px;
}
.legend-swatch {
@@ -1784,6 +1815,13 @@ input[type="radio"] {
font-size: 12px;
}
+.nodes-col--protocol {
+ width: 20px;
+ min-width: 20px;
+ text-align: center;
+ padding-inline: 4px;
+}
+
.app-footer {
position: fixed;
inset-block-end: 0;
@@ -2150,8 +2188,14 @@ body.dark .legend-item:hover {
}
body.dark .legend-item[aria-pressed="true"] {
- border-color: rgba(255, 255, 255, 0.3);
- background: rgba(255, 255, 255, 0.16);
+ border-color: #5b9bd5;
+ background: rgba(91, 155, 213, 0.28);
+ color: #a8d4ff;
+ font-weight: 600;
+}
+
+body.dark .legend-protocol-toggle {
+ border-top-color: rgba(255, 255, 255, 0.15);
}
body.dark .leaflet-popup-content-wrapper,
diff --git a/web/views/shared/_nodes_table.erb b/web/views/shared/_nodes_table.erb
index bcacaed..9ac2a8e 100644
--- a/web/views/shared/_nodes_table.erb
+++ b/web/views/shared/_nodes_table.erb
@@ -17,6 +17,7 @@
+ |
|
|
|