diff --git a/alembic/versions/23dad03d2e42_add_is_mqtt_gateway_to_node.py b/alembic/versions/23dad03d2e42_add_is_mqtt_gateway_to_node.py new file mode 100644 index 0000000..749b462 --- /dev/null +++ b/alembic/versions/23dad03d2e42_add_is_mqtt_gateway_to_node.py @@ -0,0 +1,27 @@ +"""Add is_mqtt_gateway to node + +Revision ID: 23dad03d2e42 +Revises: a0c9c13e118f +Create Date: 2026-02-13 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "23dad03d2e42" +down_revision: str | None = "a0c9c13e118f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("node", sa.Column("is_mqtt_gateway", sa.Boolean(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("node", "is_mqtt_gateway") diff --git a/meshview/lang/en.json b/meshview/lang/en.json index ed1ec18..aee43bb 100644 --- a/meshview/lang/en.json +++ b/meshview/lang/en.json @@ -80,8 +80,11 @@ "last_lat": "Last Latitude", "last_long": "Last Longitude", "channel": "Channel", + "mqtt_gateway": "MQTT", "last_seen": "Last Seen", "favorite": "Favorite", + "yes": "Yes", + "no": "No", "time_just_now": "just now", "time_min_ago": "min ago", @@ -96,8 +99,9 @@ "view_packet_details": "More details" }, - "map": { + "map": { "show_routers_only": "Show Routers Only", + "show_mqtt_only": "Show MQTT Gateways Only", "share_view": "Share This View", "reset_filters": "Reset Filters To Defaults", "unmapped_packets_title": "Unmapped Packets", @@ -105,8 +109,11 @@ "channel_label": "Channel:", "model_label": "Model:", "role_label": "Role:", + "mqtt_gateway": "MQTT Gateway:", "last_seen": "Last seen:", "firmware": "Firmware:", + "yes": "Yes", + "no": "No", "link_copied": "Link Copied!", "legend_traceroute": "Traceroute (with arrows)", "legend_neighbor": "Neighbor" @@ -192,6 +199,7 @@ "hw_model": "Hardware Model", "firmware": "Firmware", "role": "Role", + "mqtt_gateway": "MQTT Gateway", "channel": "Channel", "latitude": "Latitude", "longitude": "Longitude", @@ -214,6 +222,8 @@ "last_24h": "24h", "packets_sent": "Packets sent", "times_seen": "Times seen", + "yes": "Yes", + "no": "No", "copy_import_url": "Copy Import URL", "show_qr_code": "Show QR Code", "toggle_coverage": "Predicted Coverage", diff --git a/meshview/lang/es.json b/meshview/lang/es.json index ee7371f..a99d309 100644 --- a/meshview/lang/es.json +++ b/meshview/lang/es.json @@ -78,8 +78,11 @@ "last_lat": "Última latitud", "last_long": "Última longitud", "channel": "Canal", + "mqtt_gateway": "MQTT", "last_seen": "Última vez visto", "favorite": "Favorito", + "yes": "Sí", + "no": "No", "time_just_now": "justo ahora", "time_min_ago": "min atrás", "time_hr_ago": "h atrás", @@ -96,6 +99,7 @@ "map": { "filter_routers_only": "Mostrar solo enrutadores", "show_routers_only": "Mostrar solo enrutadores", + "show_mqtt_only": "Mostrar solo gateways MQTT", "share_view": "Compartir esta vista", "reset_filters": "Restablecer filtros", "unmapped_packets_title": "Paquetes sin mapa", @@ -103,8 +107,11 @@ "channel_label": "Canal:", "model_label": "Modelo:", "role_label": "Rol:", + "mqtt_gateway": "Gateway MQTT:", "last_seen": "Visto por última vez:", "firmware": "Firmware:", + "yes": "Sí", + "no": "No", "link_copied": "¡Enlace copiado!", "legend_traceroute": "Ruta de traceroute (flechas de dirección)", "legend_neighbor": "Vínculo de vecinos" @@ -178,6 +185,7 @@ "hw_model": "Modelo de Hardware", "firmware": "Firmware", "role": "Rol", + "mqtt_gateway": "Gateway MQTT", "channel": "Canal", "latitude": "Latitud", "longitude": "Longitud", @@ -200,6 +208,8 @@ "last_24h": "24h", "packets_sent": "Paquetes enviados", "times_seen": "Veces visto", + "yes": "Sí", + "no": "No", "copy_import_url": "Copiar URL de importación", "show_qr_code": "Mostrar código QR", "toggle_coverage": "Cobertura predicha", diff --git a/meshview/models.py b/meshview/models.py index 05765a6..7302eca 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -20,6 +20,7 @@ class Node(Base): last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True) last_long: Mapped[int] = mapped_column(BigInteger, nullable=True) channel: Mapped[str] = mapped_column(nullable=True) + is_mqtt_gateway: Mapped[bool] = mapped_column(nullable=True) first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True) last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True) diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index a69899b..7ec7ad2 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -2,7 +2,7 @@ import logging import re import time -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.exc import IntegrityError @@ -15,6 +15,8 @@ from meshview.models import Node, NodePublicKey, Packet, PacketSeen, Traceroute logger = logging.getLogger(__name__) +MQTT_GATEWAY_CACHE: set[int] = set() + async def process_envelope(topic, env): # MAP_REPORT_APP @@ -131,6 +133,12 @@ async def process_envelope(topic, env): else: node_id = int(env.gateway_id[1:], 16) + if node_id not in MQTT_GATEWAY_CACHE: + MQTT_GATEWAY_CACHE.add(node_id) + await session.execute( + update(Node).where(Node.node_id == node_id).values(is_mqtt_gateway=True) + ) + result = await session.execute( select(PacketSeen).where( PacketSeen.packet_id == env.packet.id, @@ -266,3 +274,11 @@ async def process_envelope(topic, env): ) await session.commit() + + +async def load_gateway_cache(): + async with mqtt_database.async_session() as session: + result = await session.execute( + select(Node.node_id).where(Node.is_mqtt_gateway == True) # noqa: E712 + ) + MQTT_GATEWAY_CACHE.update(result.scalars().all()) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index e55d05b..f9832af 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -110,6 +110,8 @@
Show Routers Only + + Show MQTT Gateways Only
@@ -374,6 +376,7 @@ fetch('/api/nodes?days_active=3') role: n.role || "", firmware: n.firmware || "", last_seen_us: n.last_seen_us || null, + is_mqtt_gateway: n.is_mqtt_gateway === true, isRouter: (n.role||"").toLowerCase().includes("router") })); @@ -416,6 +419,9 @@ function renderNodesOnMap(){ ${node.channel}
${node.hw_model}
${node.role}
+ ${ + node.is_mqtt_gateway ? (mapTranslations.yes || "Yes") : (mapTranslations.no || "No") + }
${ node.last_seen_us @@ -633,10 +639,14 @@ function createChannelFilters(){ }); const routerOnly=document.getElementById("filter-routers-only"); + const mqttOnly=document.getElementById("filter-mqtt-only"); routerOnly.checked = saved["routersOnly"] || false; + mqttOnly.checked = saved["mqttOnly"] || false; routerOnly.addEventListener("change", saveFiltersToLocalStorage); routerOnly.addEventListener("change", updateNodeVisibility); + mqttOnly.addEventListener("change", saveFiltersToLocalStorage); + mqttOnly.addEventListener("change", updateNodeVisibility); updateNodeVisibility(); } @@ -647,12 +657,14 @@ function saveFiltersToLocalStorage(){ state[ch] = document.getElementById(`filter-channel-${ch}`).checked; }); state["routersOnly"] = document.getElementById("filter-routers-only").checked; + state["mqttOnly"] = document.getElementById("filter-mqtt-only").checked; localStorage.setItem("mapFilters", JSON.stringify(state)); } function updateNodeVisibility(){ const routerOnly = document.getElementById("filter-routers-only").checked; + const mqttOnly = document.getElementById("filter-mqtt-only").checked; const activeChannels = [...channelSet].filter(ch => document.getElementById(`filter-channel-${ch}`).checked ); @@ -662,6 +674,7 @@ function updateNodeVisibility(){ if(marker){ const visible = (!routerOnly || n.isRouter) && + (!mqttOnly || n.is_mqtt_gateway) && activeChannels.includes(n.channel); visible ? map.addLayer(marker) : map.removeLayer(marker); @@ -692,6 +705,7 @@ function shareCurrentView() { function resetFiltersToDefaults(){ document.getElementById("filter-routers-only").checked = false; + document.getElementById("filter-mqtt-only").checked = false; channelSet.forEach(ch => { document.getElementById(`filter-channel-${ch}`).checked = true; }); diff --git a/meshview/templates/node.html b/meshview/templates/node.html index 6142a67..334c492 100644 --- a/meshview/templates/node.html +++ b/meshview/templates/node.html @@ -366,6 +366,7 @@
Firmware:
Role:
+
MQTT Gateway:
Channel:
Latitude:
Longitude:
@@ -710,6 +711,8 @@ async function loadNodeInfo(){ document.getElementById("info-hw-model").textContent = node.hw_model ?? "—"; document.getElementById("info-firmware").textContent = node.firmware ?? "—"; document.getElementById("info-role").textContent = node.role ?? "—"; + document.getElementById("info-mqtt-gateway").textContent = + node.is_mqtt_gateway ? (nodeTranslations.yes || "Yes") : (nodeTranslations.no || "No"); document.getElementById("info-channel").textContent = node.channel ?? "—"; document.getElementById("info-lat").textContent = diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html index 70c8e9a..58aa0d6 100644 --- a/meshview/templates/nodelist.html +++ b/meshview/templates/nodelist.html @@ -266,13 +266,14 @@ select, .export-btn, .search-box, .clear-btn { Last Latitude Last Longitude Channel + MQTT Last Seen - + Loading nodes... @@ -448,7 +449,7 @@ document.addEventListener("DOMContentLoaded", async function() { setStatus(""); } catch (err) { tbody.innerHTML = ` - + ${nodelistTranslations.error_loading_nodes || "Error loading nodes"} `; setStatus(""); @@ -583,7 +584,7 @@ document.addEventListener("DOMContentLoaded", async function() { if (!nodes.length) { if (shouldRenderTable) { tbody.innerHTML = ` - + ${nodelistTranslations.no_nodes_found || "No nodes found"} `; @@ -613,6 +614,7 @@ document.addEventListener("DOMContentLoaded", async function() { ${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"} ${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"} ${node.channel || "N/A"} + ${node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No")} ${timeAgoFromMs(node.last_seen_ms)} diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py index 08158ea..e2cb2e4 100644 --- a/meshview/web_api/api.py +++ b/meshview/web_api/api.py @@ -117,6 +117,7 @@ async def api_nodes(request): "last_lat": getattr(n, "last_lat", None), "last_long": getattr(n, "last_long", None), "channel": n.channel, + "is_mqtt_gateway": getattr(n, "is_mqtt_gateway", None), # "last_update": n.last_update.isoformat(), "first_seen_us": n.first_seen_us, "last_seen_us": n.last_seen_us, diff --git a/startdb.py b/startdb.py index f0dbec6..08098d6 100644 --- a/startdb.py +++ b/startdb.py @@ -239,6 +239,7 @@ async def load_database_from_mqtt( # ------------------------- async def main(): check_optional_deps() + await mqtt_store.load_gateway_cache() logger = logging.getLogger(__name__) # Initialize database