diff --git a/alembic/versions/44612e74a798_add_last_altitude_to_node.py b/alembic/versions/44612e74a798_add_last_altitude_to_node.py new file mode 100644 index 0000000..26d29c9 --- /dev/null +++ b/alembic/versions/44612e74a798_add_last_altitude_to_node.py @@ -0,0 +1,29 @@ +"""add last altitude to node + +Revision ID: 44612e74a798 +Revises: 4d2a7c9e1b30 +Create Date: 2026-10-28 10:37:11.340792 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '44612e74a798' +down_revision: str | None = '4d2a7c9e1b30' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("node") as batch_op: + batch_op.add_column(sa.Column("last_altitude", sa.BigInteger(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("node") as batch_op: + batch_op.drop_column("last_altitude") diff --git a/docs/API_Documentation.md b/docs/API_Documentation.md index 8e77cc8..cf3698e 100644 --- a/docs/API_Documentation.md +++ b/docs/API_Documentation.md @@ -73,6 +73,7 @@ Response Example "role": "client", "last_lat": 377749000, "last_long": -1224194000, + "last_altitude": 125, "channel": "main", "is_mqtt_gateway": true, "first_seen_us": 1736283723456789, @@ -82,6 +83,12 @@ Response Example } ``` +`last_altitude` is the latest reported altitude in meters above sea level, or `null` +when unknown. It updates with valid node locations from position packets and map reports. +Position packets without altitude preserve the previous value. Map reports lack altitude +field presence, so zero values in map reports also preserve the previous value. +Existing nodes receive altitude as new reports arrive. + --- ## 2. Packets API diff --git a/meshview/models.py b/meshview/models.py index a4beb21..bef3b79 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -21,6 +21,7 @@ class Node(Base): role: Mapped[str] = mapped_column(nullable=True) last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True) last_long: Mapped[int] = mapped_column(BigInteger, nullable=True) + last_altitude: Mapped[int | None] = 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) diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index 6f6d12d..8632f62 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -224,6 +224,9 @@ async def process_envelope(topic, env): if has_location: node.last_lat = map_report.latitude_i node.last_long = map_report.longitude_i + # MapReport has no presence flag for altitude. + if map_report.altitude != 0: + node.last_altitude = map_report.altitude node.firmware = map_report.firmware_version node.last_seen_us = now_us if node.first_seen_us is None: @@ -244,6 +247,9 @@ async def process_envelope(topic, env): if has_location: node.last_lat = map_report.latitude_i node.last_long = map_report.longitude_i + # MapReport has no presence flag for altitude. + if map_report.altitude != 0: + node.last_altitude = map_report.altitude session.add(node) except Exception as e: print(f"Error processing MAP_REPORT_APP: {e}") @@ -427,6 +433,8 @@ async def process_envelope(topic, env): now_us = int(time.time() * 1_000_000) node.last_lat = position.latitude_i node.last_long = position.longitude_i + if position.HasField("altitude"): + node.last_altitude = position.altitude node.last_seen_us = now_us if node.first_seen_us is None: node.first_seen_us = now_us diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py index 7e91fa0..24b7d42 100644 --- a/meshview/web_api/api.py +++ b/meshview/web_api/api.py @@ -276,6 +276,7 @@ async def api_nodes(request): "role": n.role, "last_lat": getattr(n, "last_lat", None), "last_long": getattr(n, "last_long", None), + "last_altitude": n.last_altitude, "channel": n.channel, "is_mqtt_gateway": getattr(n, "is_mqtt_gateway", None), # "last_update": n.last_update.isoformat(), diff --git a/tests/test_node_altitude.py b/tests/test_node_altitude.py new file mode 100644 index 0000000..4500406 --- /dev/null +++ b/tests/test_node_altitude.py @@ -0,0 +1,48 @@ +"""Persist reported altitudes without losing them when a report omits altitude.""" + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from meshtastic.protobuf.mesh_pb2 import Position +from meshtastic.protobuf.mqtt_pb2 import MapReport, ServiceEnvelope +from meshtastic.protobuf.portnums_pb2 import PortNum +from meshview import mqtt_database, mqtt_store +from meshview.models import Base, Node + + +@pytest.mark.asyncio +@pytest.mark.parametrize("report_type", [Position, MapReport]) +async def test_altitude_updates(monkeypatch, report_type): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + sessions = async_sessionmaker(engine, expire_on_commit=False) + monkeypatch.setattr(mqtt_database, "async_session", sessions, raising=False) + monkeypatch.setattr(mqtt_store, "MQTT_GATEWAY_CACHE", set()) + port = PortNum.POSITION_APP if report_type is Position else PortNum.MAP_REPORT_APP + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + cases = [ + (None, 200000000, None), + (125, 200000000, 125), + (None, 200000000, 125), + (-30, 200000000, -30), + (900, 0, -30), # Invalid location must not update altitude. + (0, 200000000, 0 if report_type is Position else -30), + ] + for packet_id, (altitude, coordinate, expected) in enumerate(cases, start=1): + report = report_type(latitude_i=coordinate, longitude_i=coordinate) + if altitude is not None: + report.altitude = altitude + env = ServiceEnvelope(gateway_id="!00000002", channel_id="test") + env.packet.id = packet_id + setattr(env.packet, "from", 123) + env.packet.decoded.portnum = port + env.packet.decoded.payload = report.SerializeToString() + await mqtt_store.process_envelope("test", env) + async with sessions() as session: + node = (await session.execute(select(Node).where(Node.node_id == 123))).scalar_one() + assert node.last_altitude == expected + assert node.to_dict()["last_altitude"] == expected + finally: + await engine.dispose()