diff --git a/alembic/versions/20260109_2004_4e2e787a1660_add_lat_lon_columns_to_nodes.py b/alembic/versions/20260109_2004_4e2e787a1660_add_lat_lon_columns_to_nodes.py new file mode 100644 index 0000000..2ced353 --- /dev/null +++ b/alembic/versions/20260109_2004_4e2e787a1660_add_lat_lon_columns_to_nodes.py @@ -0,0 +1,37 @@ +"""add lat lon columns to nodes + +Revision ID: 4e2e787a1660 +Revises: aa1162502616 +Create Date: 2026-01-09 20:04:04.273741+00:00 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "4e2e787a1660" +down_revision: Union[str, None] = "aa1162502616" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("nodes", schema=None) as batch_op: + batch_op.add_column(sa.Column("lat", sa.Float(), nullable=True)) + batch_op.add_column(sa.Column("lon", sa.Float(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("nodes", schema=None) as batch_op: + batch_op.drop_column("lon") + batch_op.drop_column("lat") + + # ### end Alembic commands ### diff --git a/src/meshcore_hub/collector/handlers/contacts.py b/src/meshcore_hub/collector/handlers/contacts.py index 9e2027e..7c0b264 100644 --- a/src/meshcore_hub/collector/handlers/contacts.py +++ b/src/meshcore_hub/collector/handlers/contacts.py @@ -47,6 +47,10 @@ def handle_contact( # Device uses 'adv_name' for the advertised name name = payload.get("adv_name") or payload.get("name") + # GPS coordinates (optional) + lat = payload.get("adv_lat") + lon = payload.get("adv_lon") + logger.info(f"Processing contact: {contact_key[:12]}... adv_name={name}") # Device uses numeric 'type' field, convert to string @@ -73,6 +77,11 @@ def handle_contact( node.name = name if node_type and not node.adv_type: node.adv_type = node_type + # Update GPS coordinates if provided + if lat is not None: + node.lat = lat + if lon is not None: + node.lon = lon # Do NOT update last_seen for contact sync - only advertisement events # should update last_seen since that's when the node was actually seen else: @@ -84,6 +93,8 @@ def handle_contact( adv_type=node_type, first_seen=now, last_seen=None, # Will be set when we receive an advertisement + lat=lat, + lon=lon, ) session.add(node) logger.info(f"Created node from contact: {contact_key[:12]}... ({name})") diff --git a/src/meshcore_hub/common/models/node.py b/src/meshcore_hub/common/models/node.py index 602a76a..7657b3a 100644 --- a/src/meshcore_hub/common/models/node.py +++ b/src/meshcore_hub/common/models/node.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Optional -from sqlalchemy import DateTime, Index, Integer, String +from sqlalchemy import DateTime, Float, Index, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now @@ -23,6 +23,8 @@ class Node(Base, UUIDMixin, TimestampMixin): flags: Capability/status flags bitmask first_seen: Timestamp of first advertisement last_seen: Timestamp of most recent activity + lat: GPS latitude coordinate (if available) + lon: GPS longitude coordinate (if available) created_at: Record creation timestamp updated_at: Record update timestamp """ @@ -57,6 +59,14 @@ class Node(Base, UUIDMixin, TimestampMixin): default=None, nullable=True, ) + lat: Mapped[Optional[float]] = mapped_column( + Float, + nullable=True, + ) + lon: Mapped[Optional[float]] = mapped_column( + Float, + nullable=True, + ) # Relationships tags: Mapped[list["NodeTag"]] = relationship( diff --git a/src/meshcore_hub/common/schemas/nodes.py b/src/meshcore_hub/common/schemas/nodes.py index c5fed41..2f6fdbf 100644 --- a/src/meshcore_hub/common/schemas/nodes.py +++ b/src/meshcore_hub/common/schemas/nodes.py @@ -62,6 +62,8 @@ class NodeRead(BaseModel): last_seen: Optional[datetime] = Field( default=None, description="Last activity timestamp" ) + lat: Optional[float] = Field(default=None, description="GPS latitude coordinate") + lon: Optional[float] = Field(default=None, description="GPS longitude coordinate") created_at: datetime = Field(..., description="Record creation timestamp") updated_at: datetime = Field(..., description="Record update timestamp") tags: list[NodeTagRead] = Field(default_factory=list, description="Node tags")