diff --git a/README.md b/README.md index 42e9dee..b4ae896 100644 --- a/README.md +++ b/README.md @@ -100,27 +100,38 @@ See [README-Docker.md](README-Docker.md) for container deployment and [docs/](do Samples of currently running instances: -- https://meshview.bayme.sh (SF Bay Area - USA) -- https://www.svme.sh (Sacramento Valley - USA) -- https://meshview.nyme.sh (New York - USA) -- https://meshview.socalmesh.org (Los Angenles - USA) -- https://map.wpamesh.net (Western Pennsylvania - USA) -- https://meshview.chicagolandmesh.org (Chicago - USA) -- https://meshview.freq51.net/ (Salt Lake City - USA) -- https://meshview.mt.gt (Canada) -- https://canadaverse.org (Canada) -- https://meshview.meshtastic.es (Spain) -- https://view.mtnme.sh (North Georgia / East Tennessee - USA) -- https://meshview.lsinfra.de (Hessen - Germany) -- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts - USA) -- https://meshview.louisianamesh.org (Louisiana - USA) -- https://www.swlamesh.com (Southwest Louisiana- USA) -- https://meshview.meshcolombia.co (Colombia) -- https://meshview-salzburg.jmt.gr (Salzburg / Austria) -- https://map.cromesh.eu (Coatia) -- https://view.meshdresden.eu (Dresden / Germany) -- https://meshview.meshoregon.com (Oregon - USA) -- https://meshview.gamesh.net (Georgia - USA) +| Area | URL | Version | Nodes | Last Updated | +| --- | --- | --- | ---: | --- | +| Polski Mesh | https://meshview.format.ovh | 3.0.6 (65386be) | 4,827 | 4/1/2026, 12:12:25 PM | +| Poland | https://meshview.szastan.pl | 3.0.5 (unknown) | 4,194 | 4/1/2026, 12:12:39 PM | +| Spain | https://meshview.meshtastic.es | 3.0.5 (unknown) | 2,466 | 4/1/2026, 12:11:17 PM | +| San Francisco Bay Area | https://meshview.bayme.sh | 3.0.5 (bdf70da) | 1,983 | 4/1/2026, 12:11:08 PM | +| Southern California | https://meshview.socalmesh.org | 3.0.5 (unknown) | 1,205 | 4/1/2026, 12:11:50 PM | +| Oregon (USA) | https://meshview.meshoregon.com | 3.0.5 (bdf70da) | 1,167 | 4/1/2026, 12:11:41 PM | +| Australia/ New Zeland | https://my.meshview.world/anz | plus 1.0.0 | 1,165 | 4/1/2026, 12:11:59 PM | +| Hessen - Germany | https://meshview.lsinfra.de | 3.0.5 (unknown) | 1,082 | 4/1/2026, 12:11:30 PM | +| New York City | https://meshview.nyme.sh | 3.0.5 (bdf70da) | 984 | 4/1/2026, 12:11:26 PM | +| Salt Lake City (USA) | https://meshview.freq51.net | — | 801 | 4/1/2026, 12:11:36 PM | +| Salzburg / Austria | https://meshview-salzburg.jmt.gr | — | 748 | 4/1/2026, 12:11:21 PM | +| North Georgia / East Tennessee - USA | https://view.mtnme.sh | 3.0.3 (ff30623) | 730 | 4/1/2026, 12:11:33 PM | +| Canada | https://mv.canadaverse.org | 3.0.1 (unknown) | 674 | 4/1/2026, 12:12:06 PM | +| Pioneer Valley, Massachusetts (USA) | https://meshview.pvmesh.org | 3.0.4 (018e16e) | 560 | 4/1/2026, 12:11:44 PM | +| Western Pennsylvania (USA) | https://map.wpamesh.net | 3.0.5 (unknown) | 447 | 4/1/2026, 12:11:38 PM | +| Great Britan | https://my.meshview.world/gb | plus 1.0.0 | 421 | 4/1/2026, 12:12:03 PM | +| Chicago (USA) | https://meshview.chicagolandmesh.org | 3.0.5 (unknown) | 420 | 4/1/2026, 12:12:41 PM | +| Georgia (USA) | https://meshview.gamesh.net | 3.0.1 (unknown) | 384 | 4/1/2026, 12:11:45 PM | +| Brazil | https://my.meshview.world/brazil | plus 1.0.0 | 364 | 4/1/2026, 12:12:01 PM | +| Mexico | https://my.meshview.world/mexico | plus 1.0.0 | 268 | 4/1/2026, 12:11:59 PM | +| Northeast Ohio | https://meshview.neomesh.org | 3.0.5 (unknown) | 264 | 4/1/2026, 12:11:52 PM | +| South Africa | https://my.meshview.world/za | plus 1.0.0 | 263 | 4/1/2026, 12:12:07 PM | +| Argentina | https://my.meshview.world/argentina | plus 1.0.0 | 258 | 4/1/2026, 12:12:00 PM | +| Northwest Indiana (USA) | https://meshview.nwimesh.net | 3.0.5 (2cc53dc) | 219 | 4/1/2026, 12:12:08 PM | +| Louisiana (USA) | https://meshview.louisianamesh.org | — | 78 | 4/1/2026, 12:11:51 PM | +| Chile | https://my.meshview.world/chile | plus 1.0.0 | 58 | 4/1/2026, 12:12:06 PM | +| Colombia | https://meshview.meshcolombia.co | 3.0.5 (2cc53dc) | 55 | 4/1/2026, 12:11:46 PM | +| Croatia | https://map.cromesh.eu | — | 0 | 4/1/2026, 12:11:45 PM | +| Saint Louis (USA) | https://meshview.meshstl.org | 3.0.5 (2cc53dc) | 0 | 4/1/2026, 12:11:51 PM | +| Southwest Louisiana (USA) | https://www.swlamesh.com | — | 0 | 4/1/2026, 12:11:46 PM | --- @@ -652,17 +663,6 @@ Check the log file to see it the script run at the specific time. --- -## Testing - -MeshView includes a test suite using pytest. For detailed testing documentation, see [README-testing.md](README-testing.md). - -Quick start: -```bash -./env/bin/pytest tests/test_api_simple.py -v -``` - ---- - ## Technical Documentation For more detailed technical documentation including database migrations, architecture details, and advanced topics, see the [docs/](docs/) directory. diff --git a/alembic/versions/4f1d2a9c8b71_add_daily_snapshot_table.py b/alembic/versions/4f1d2a9c8b71_add_daily_snapshot_table.py new file mode 100644 index 0000000..7ff18c4 --- /dev/null +++ b/alembic/versions/4f1d2a9c8b71_add_daily_snapshot_table.py @@ -0,0 +1,35 @@ +"""Add daily_snapshot table + +Revision ID: 4f1d2a9c8b71 +Revises: 23dad03d2e42 +Create Date: 2026-03-05 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "4f1d2a9c8b71" +down_revision: str | None = "23dad03d2e42" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "daily_snapshot", + sa.Column("snapshot_date", sa.Date(), nullable=False), + sa.Column("node_count", sa.BigInteger(), nullable=False), + sa.Column("packet_count", sa.BigInteger(), nullable=False), + sa.Column("gateway_count", sa.BigInteger(), nullable=False), + sa.Column("captured_at_us", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("snapshot_date"), + ) + + +def downgrade() -> None: + op.drop_table("daily_snapshot") diff --git a/alembic/versions/c6b1d8f2a9e3_compat_placeholder.py b/alembic/versions/c6b1d8f2a9e3_compat_placeholder.py new file mode 100644 index 0000000..772303b --- /dev/null +++ b/alembic/versions/c6b1d8f2a9e3_compat_placeholder.py @@ -0,0 +1,22 @@ +"""Compatibility placeholder for removed revision c6b1d8f2a9e3. + +Revision ID: c6b1d8f2a9e3 +Revises: 4f1d2a9c8b71 +Create Date: 2026-03-05 00:00:00.000000 +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "c6b1d8f2a9e3" +down_revision: str | None = "4f1d2a9c8b71" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/meshtastic/protobuf/UPSTREAM_REV.txt b/meshtastic/protobuf/UPSTREAM_REV.txt index 63b6ee9..3a38980 100644 --- a/meshtastic/protobuf/UPSTREAM_REV.txt +++ b/meshtastic/protobuf/UPSTREAM_REV.txt @@ -1 +1 @@ -e1a6b3a868d735da72cd6c94c574d655129d390a +cb1f89372a70b0d4b4f8caf05aec28de8d4a13e0 diff --git a/meshview/__version__.py b/meshview/__version__.py index de9f5a7..6f7f517 100644 --- a/meshview/__version__.py +++ b/meshview/__version__.py @@ -3,8 +3,8 @@ import subprocess from pathlib import Path -__version__ = "3.0.5" -__release_date__ = "2026-2-6" +__version__ = "3.0.6" +__release_date__ = "2026-3-6" def get_git_revision(): diff --git a/meshview/lang/en.json b/meshview/lang/en.json index 302fe7d..1b2691a 100644 --- a/meshview/lang/en.json +++ b/meshview/lang/en.json @@ -51,7 +51,9 @@ "chat": { "chat_title": "Chats:", "replying_to": "Replying to:", - "view_packet_details": "View packet details" + "view_packet_details": "View packet details", + "channel": "Channel", + "all_channels": "All Channels" }, "nodelist": { "search_placeholder": "Search by name or ID...", @@ -127,6 +129,8 @@ "total_gateways": "Total Gateways", "total_packets": "Total Packets", "total_packets_seen": "Total Packets Seen", + "daily_snapshot_packets_history": "Daily Packet Snapshot History", + "daily_snapshot_nodes_gateways_history": "Daily Node/Gateway Snapshot History", "packets_per_day_all": "Packets per Day - All Ports (Last 14 Days)", "packets_per_day_text": "Packets per Day - Text Messages (Port 1, Last 14 Days)", "packets_per_hour_all": "Packets per Hour - All Ports", @@ -178,6 +182,8 @@ "packet_id": "Packet ID", "from": "From", "to": "To", + "channel": "Channel", + "all_channels": "All Channels", "port": "Port", "links": "Links", "unknown_app": "UNKNOWN APP", diff --git a/meshview/lang/es.json b/meshview/lang/es.json index 573d2a4..e3d6eb3 100644 --- a/meshview/lang/es.json +++ b/meshview/lang/es.json @@ -52,7 +52,9 @@ "chat": { "chat_title": "Conversaciones:", "replying_to": "Respondiendo a:", - "view_packet_details": "Ver detalles del paquete" + "view_packet_details": "Ver detalles del paquete", + "channel": "Canal", + "all_channels": "Todos los canales" }, "nodelist": { @@ -123,6 +125,8 @@ "total_gateways": "Gateways Totales", "total_packets": "Paquetes Totales", "total_packets_seen": "Paquetes Totales Vistos", + "daily_snapshot_packets_history": "Historial Diario de Instantáneas de Paquetes (Todos los Días Disponibles)", + "daily_snapshot_nodes_gateways_history": "Historial Diario de Instantáneas de Nodos/Gateways (Todos los Días Disponibles)", "packets_per_day_all": "Paquetes por Día - Todos los Puertos (Últimos 14 Días)", "packets_per_day_text": "Paquetes por Día - Mensajes de Texto (Puerto 1, Últimos 14 Días)", "packets_per_hour_all": "Paquetes por Hora - Todos los Puertos", @@ -177,6 +181,8 @@ "packet_id": "ID de paquete", "from": "De", "to": "A", + "channel": "Canal", + "all_channels": "Todos los canales", "port": "Puerto", "direct_to_mqtt": "Directo a MQTT", "all_broadcast": "Todos" diff --git a/meshview/models.py b/meshview/models.py index 7302eca..b450336 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -1,4 +1,6 @@ -from sqlalchemy import BigInteger, ForeignKey, Index, desc +from datetime import date + +from sqlalchemy import BigInteger, Date, ForeignKey, Index, desc from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -119,3 +121,13 @@ class NodePublicKey(Base): Index("idx_node_public_key_node_id", "node_id"), Index("idx_node_public_key_public_key", "public_key"), ) + + +class DailySnapshot(Base): + __tablename__ = "daily_snapshot" + + snapshot_date: Mapped[date] = mapped_column(Date, primary_key=True) + node_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + packet_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + gateway_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + captured_at_us: Mapped[int] = mapped_column(BigInteger, nullable=False) diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index 7ec7ad2..82ddd0a 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -1,8 +1,9 @@ import logging import re import time +from datetime import UTC, datetime -from sqlalchemy import select, update +from sqlalchemy import func, 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 @@ -11,13 +12,69 @@ from meshtastic.protobuf.config_pb2 import Config from meshtastic.protobuf.mesh_pb2 import HardwareModel from meshtastic.protobuf.portnums_pb2 import PortNum from meshview import decode_payload, mqtt_database -from meshview.models import Node, NodePublicKey, Packet, PacketSeen, Traceroute +from meshview.models import DailySnapshot, Node, NodePublicKey, Packet, PacketSeen, Traceroute logger = logging.getLogger(__name__) MQTT_GATEWAY_CACHE: set[int] = set() +async def capture_daily_snapshot() -> None: + today = datetime.now(UTC).date() + + async with mqtt_database.async_session() as session: + node_count = (await session.execute(select(func.count()).select_from(Node))).scalar_one() + packet_count = ( + await session.execute(select(func.count()).select_from(Packet)) + ).scalar_one() + gateway_count = ( + await session.execute( + select(func.count()).select_from(Node).where(Node.is_mqtt_gateway.is_(True)) + ) + ).scalar_one() + captured_at_us = int(time.time() * 1_000_000) + values = { + "snapshot_date": today, + "node_count": node_count, + "packet_count": packet_count, + "gateway_count": gateway_count, + "captured_at_us": captured_at_us, + } + + dialect = session.get_bind().dialect.name + stmt = None + if dialect == "sqlite": + stmt = ( + sqlite_insert(DailySnapshot) + .values(**values) + .on_conflict_do_update(index_elements=["snapshot_date"], set_=values) + ) + elif dialect == "postgresql": + stmt = ( + pg_insert(DailySnapshot) + .values(**values) + .on_conflict_do_update(index_elements=["snapshot_date"], set_=values) + ) + + if stmt is not None: + await session.execute(stmt) + else: + snapshot = ( + await session.execute( + select(DailySnapshot).where(DailySnapshot.snapshot_date == today) + ) + ).scalar_one_or_none() + if snapshot is None: + session.add(DailySnapshot(**values)) + else: + snapshot.node_count = node_count + snapshot.packet_count = packet_count + snapshot.gateway_count = gateway_count + snapshot.captured_at_us = captured_at_us + + await session.commit() + + async def process_envelope(topic, env): # MAP_REPORT_APP if env.packet.decoded.portnum == PortNum.MAP_REPORT_APP: @@ -124,7 +181,7 @@ async def process_envelope(topic, env): except IntegrityError: pass - # --- PacketSeen (no conflict handling here, normal insert) + # --- PacketSeen insert with conflict-safe handling if not env.gateway_id: print("WARNING: Missing gateway_id, skipping PacketSeen entry") @@ -139,28 +196,43 @@ async def process_envelope(topic, env): 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, - PacketSeen.node_id == node_id, - PacketSeen.rx_time == env.packet.rx_time, + now_us = int(time.time() * 1_000_000) + seen_values = { + "packet_id": env.packet.id, + "node_id": int(env.gateway_id[1:], 16), + "channel": env.channel_id, + "rx_time": env.packet.rx_time, + "rx_snr": env.packet.rx_snr, + "rx_rssi": env.packet.rx_rssi, + "hop_limit": env.packet.hop_limit, + "hop_start": env.packet.hop_start, + "topic": topic, + "import_time_us": now_us, + } + dialect = session.get_bind().dialect.name + seen_stmt = None + if dialect == "sqlite": + seen_stmt = ( + sqlite_insert(PacketSeen) + .values(**seen_values) + .on_conflict_do_nothing(index_elements=["packet_id", "node_id", "rx_time"]) ) - ) - if not result.scalar_one_or_none(): - now_us = int(time.time() * 1_000_000) - seen = PacketSeen( - packet_id=env.packet.id, - node_id=int(env.gateway_id[1:], 16), - channel=env.channel_id, - rx_time=env.packet.rx_time, - rx_snr=env.packet.rx_snr, - rx_rssi=env.packet.rx_rssi, - hop_limit=env.packet.hop_limit, - hop_start=env.packet.hop_start, - topic=topic, - import_time_us=now_us, + elif dialect == "postgresql": + seen_stmt = ( + pg_insert(PacketSeen) + .values(**seen_values) + .on_conflict_do_nothing(index_elements=["packet_id", "node_id", "rx_time"]) ) - session.add(seen) + + if seen_stmt is not None: + await session.execute(seen_stmt) + else: + try: + async with session.begin_nested(): + session.add(PacketSeen(**seen_values)) + await session.flush() + except IntegrityError: + pass # --- NODEINFO_APP handling if env.packet.decoded.portnum == PortNum.NODEINFO_APP: @@ -172,7 +244,12 @@ async def process_envelope(topic, env): if user.id[0] == "!" and re.fullmatch(r"[0-9a-fA-F]+", user.id[1:]): node_id = int(user.id[1:], 16) else: - node_id = None + logger.warning( + "Skipping NODEINFO_APP packet %s: unable to determine node_id", + env.packet.id, + ) + await session.commit() + return hw_model = ( HardwareModel.Name(user.hw_model) diff --git a/meshview/templates/base.html b/meshview/templates/base.html index 44f3d0d..80447ab 100644 --- a/meshview/templates/base.html +++ b/meshview/templates/base.html @@ -176,6 +176,7 @@ async function initializePage() { items.push(`${dict[key] || key}`); } } + items.push('MeshviewWorld'); menu.innerHTML = items.join(" - "); } diff --git a/meshview/templates/chat.html b/meshview/templates/chat.html index bd719fb..d724afe 100644 --- a/meshview/templates/chat.html +++ b/meshview/templates/chat.html @@ -46,6 +46,13 @@ padding-left: 10px; } .replying-to .reply-preview { color: #aaa; } +.chat-controls { + gap: 12px; + margin-bottom: 10px; +} +.chat-filter { + min-width: 220px; +} {% endblock %} {% block body %} @@ -53,10 +60,19 @@
| Packet ID | From | To | +Channel | Port | @@ -240,6 +254,7 @@ function logPacketTimes(packet) { let lastImportTimeUs = null; let updatesPaused = false; let updateInterval = 3000; +let selectedChannel = ""; async function configureFirehose() { try { @@ -249,6 +264,36 @@ async function configureFirehose() { } catch {} } +async function loadChannels() { + try { + const res = await fetch("/api/channels"); + if (!res.ok) return; + + const data = await res.json(); + const select = document.getElementById("channel-filter"); + const channels = data.channels || []; + + for (const channel of channels) { + const option = document.createElement("option"); + option.value = channel; + option.textContent = channel; + select.appendChild(option); + } + } catch (err) { + console.error("Failed loading channels:", err); + } +} + +function resetFirehose() { + lastImportTimeUs = null; + document.getElementById("packet_list").innerHTML = ""; +} + +function matchesSelectedChannel(packet) { + if (!selectedChannel) return true; + return (packet.channel || "").toLowerCase() === selectedChannel.toLowerCase(); +} + async function fetchUpdates() { if (updatesPaused) return; @@ -269,6 +314,8 @@ async function fetchUpdates() { const list = document.getElementById("packet_list"); for (const pkt of packets.reverse()) { + if (!matchesSelectedChannel(pkt)) continue; + logPacketTimes(pkt); /* FROM — includes translation */ @@ -343,12 +390,13 @@ async function fetchUpdates() {${from} | ${to} | +${pkt.channel || "—"} | ${portLabel(pkt.portnum, pkt.payload, inlineLinks)} | ||
|---|---|---|---|---|---|---|---|---|---|---|
| ${safePayload} | +${safePayload} | |||||||||