Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Meshflow/Meshflow/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
[
path("status/", StatusView.as_view(), name="status"),
path("packets/", include("packets.urls")),
path("v3/packets/", include("packets.urls_v3")),
path("meshcore/", include("meshcore_packets.urls")),
path("constellations/", include("constellations.urls")),
path("nodes/", include("nodes.urls")),
Expand Down
120 changes: 98 additions & 22 deletions Meshflow/packets/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1266,13 +1266,87 @@ def create(self, validated_data):
return packet


def _normalize_position_block(position_data, *, allow_legacy_fields):
"""Map feeder position payload to nested PositionSerializer input."""
if not allow_legacy_fields:
if "location_source" in position_data and position_data.get("meshtastic_location_source") is None:
raise serializers.ValidationError(
{
"position": {
"location_source": ["Legacy field not accepted on API v3; use meshtastic_location_source."]
}
}
)

location_source = position_data.get("meshtastic_location_source")
if allow_legacy_fields and location_source is None:
location_source = position_data.get("location_source")
if location_source in (None, ""):
location_source = "UNSET"

block = {
"reported_time": position_data.get("reported_time"),
"latitude": position_data.get("latitude"),
"longitude": position_data.get("longitude"),
"altitude": position_data.get("altitude"),
"meshtastic_location_source": location_source,
}
if position_data.get("heading") is not None:
block["heading"] = position_data.get("heading")
return block


def _normalize_device_metrics_block(metrics_data, *, allow_legacy_fields):
"""Map feeder device_metrics payload to nested DeviceMetricsSerializer input."""
if not allow_legacy_fields:
if "channel_utilization" in metrics_data and metrics_data.get("meshtastic_channel_utilization") is None:
raise serializers.ValidationError(
{
"device_metrics": {
"channel_utilization": [
"Legacy field not accepted on API v3; use meshtastic_channel_utilization."
]
}
}
)
if "air_util_tx" in metrics_data and metrics_data.get("meshtastic_air_util_tx") is None:
raise serializers.ValidationError(
{
"device_metrics": {
"air_util_tx": ["Legacy field not accepted on API v3; use meshtastic_air_util_tx."]
}
}
)

channel_utilization = metrics_data.get("meshtastic_channel_utilization")
if channel_utilization is None and allow_legacy_fields:
channel_utilization = metrics_data.get("channel_utilization")
if channel_utilization is None:
channel_utilization = 0.0

air_util_tx = metrics_data.get("meshtastic_air_util_tx")
if air_util_tx is None and allow_legacy_fields:
air_util_tx = metrics_data.get("air_util_tx")
if air_util_tx is None:
air_util_tx = 0.0

return {
"reported_time": metrics_data.get("reported_time"),
"battery_level": metrics_data.get("battery_level"),
"voltage": metrics_data.get("voltage"),
"meshtastic_channel_utilization": channel_utilization,
"meshtastic_air_util_tx": air_util_tx,
"uptime_seconds": metrics_data.get("uptime_seconds"),
}


class PositionSerializer(serializers.Serializer):
logged_time = serializers.DateTimeField(read_only=True, default=django_timezone.now)
reported_time = serializers.DateTimeField(required=True)
latitude = serializers.FloatField()
longitude = serializers.FloatField()
altitude = serializers.FloatField()
heading = serializers.FloatField()
heading = serializers.FloatField(required=False, allow_null=True)
meshtastic_location_source = serializers.CharField()

def to_internal_value(self, data):
Expand All @@ -1281,10 +1355,9 @@ def to_internal_value(self, data):
validated_data = super().to_internal_value(data)

# Convert meshtastic_location_source from string to integer using LocationSource
if "meshtastic_location_source" in validated_data and validated_data["meshtastic_location_source"]:
validated_data["meshtastic_location_source"] = convert_location_source(
validated_data["meshtastic_location_source"]
)
validated_data["meshtastic_location_source"] = convert_location_source(
validated_data.get("meshtastic_location_source")
)

return validated_data

Expand All @@ -1299,8 +1372,10 @@ class DeviceMetricsSerializer(serializers.Serializer):
uptime_seconds = serializers.IntegerField()


class NodeSerializer(serializers.ModelSerializer):
"""Serializer for node information updates."""
class BaseNodeSerializer(serializers.ModelSerializer):
"""Shared node upsert serializer; subclasses set allow_legacy_fields."""

allow_legacy_fields = True

class UserSerializer(serializers.Serializer):
long_name = serializers.CharField(required=False, allow_null=True)
Expand Down Expand Up @@ -1374,25 +1449,14 @@ def to_internal_value(self, data):
# Handle position data
if "position" in data:
position_data = data.pop("position")
data["position"] = {
"reported_time": position_data.get("reported_time"),
"latitude": position_data.get("latitude"),
"longitude": position_data.get("longitude"),
"altitude": position_data.get("altitude"),
"meshtastic_location_source": position_data.get("meshtastic_location_source"),
}
data["position"] = _normalize_position_block(position_data, allow_legacy_fields=self.allow_legacy_fields)

# Handle device metrics
if "device_metrics" in data:
metrics_data = data.pop("device_metrics")
data["device_metrics"] = {
"reported_time": metrics_data.get("reported_time"),
"battery_level": metrics_data.get("battery_level"),
"voltage": metrics_data.get("voltage"),
"meshtastic_channel_utilization": metrics_data.get("meshtastic_channel_utilization"),
"meshtastic_air_util_tx": metrics_data.get("meshtastic_air_util_tx"),
"uptime_seconds": metrics_data.get("uptime_seconds"),
}
data["device_metrics"] = _normalize_device_metrics_block(
metrics_data, allow_legacy_fields=self.allow_legacy_fields
)

return super().to_internal_value(data)

Expand Down Expand Up @@ -1479,6 +1543,18 @@ def update(self, instance, validated_data):
return instance


class NodeSerializer(BaseNodeSerializer):
"""Feeder node upsert (API v2 paths): legacy field aliases + meshtastic_*."""

allow_legacy_fields = True


class NodeSerializerV3(BaseNodeSerializer):
"""Feeder node upsert (API v3 paths): meshtastic_* wire only."""

allow_legacy_fields = False


class PrefetchedPacketObservationSerializer(serializers.ModelSerializer):
"""Serializer for packet observations."""

Expand Down
11 changes: 11 additions & 0 deletions Meshflow/packets/tests/test_feeder_v3_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""URL routing for feeder API v3."""

from django.urls import reverse


def test_v3_node_upsert_url_resolves():
url = reverse(
"meshtastic-node-upsert-v3",
kwargs={"node_id": 2997338904},
)
assert url == "/api/v3/packets/2997338904/nodes/"
76 changes: 76 additions & 0 deletions Meshflow/packets/tests/test_packet_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,82 @@ def test_node_upsert_with_device_metrics_updates_nodelateststatus(self):
self.assertEqual(latest_status.uptime_seconds, 10000)
self.assertIsNotNone(latest_status.metrics_reported_time)

def test_node_upsert_v2_compat_legacy_position_and_metrics_fields(self):
"""Legacy bot wire (location_source, channel_utilization) validates on v2 serializer."""
from django.utils import timezone

data = {
"id": self.from_node.meshtastic_node_id,
"position": {
"reported_time": timezone.now().isoformat(),
"latitude": 51.5,
"longitude": -0.12,
"altitude": 5.0,
"location_source": "",
},
"device_metrics": {
"reported_time": timezone.now().isoformat(),
"battery_level": 90.0,
"voltage": 4.0,
"channel_utilization": 0.15,
"air_util_tx": 0.05,
"uptime_seconds": 5000,
},
}

serializer = NodeSerializer(instance=self.from_node, data=data, partial=True)
assert_serializer_valid(serializer)
serializer.save()

latest_status = NodeLatestStatus.objects.get(node=self.from_node)
self.assertEqual(latest_status.meshtastic_location_source, LocationSource.UNSET)
self.assertEqual(latest_status.meshtastic_channel_utilization, 0.15)
self.assertEqual(latest_status.meshtastic_air_util_tx, 0.05)

def test_node_upsert_v3_rejects_legacy_only_position_field(self):
from django.utils import timezone

from packets.serializers import NodeSerializerV3

data = {
"id": self.from_node.meshtastic_node_id,
"position": {
"reported_time": timezone.now().isoformat(),
"latitude": 51.5,
"longitude": -0.12,
"altitude": 5.0,
"location_source": "LOC_INTERNAL",
},
}

serializer = NodeSerializerV3(instance=self.from_node, data=data, partial=True)
self.assertFalse(serializer.is_valid())
self.assertIn("position", serializer.errors)

def test_node_upsert_v3_accepts_meshtastic_fields(self):
from django.utils import timezone

from packets.serializers import NodeSerializerV3

data = {
"id": self.from_node.meshtastic_node_id,
"device_metrics": {
"reported_time": timezone.now().isoformat(),
"battery_level": 80.0,
"voltage": 3.8,
"meshtastic_channel_utilization": 0.1,
"meshtastic_air_util_tx": 0.2,
"uptime_seconds": 100,
},
}

serializer = NodeSerializerV3(instance=self.from_node, data=data, partial=True)
assert_serializer_valid(serializer)
serializer.save()

latest_status = NodeLatestStatus.objects.get(node=self.from_node)
self.assertEqual(latest_status.meshtastic_channel_utilization, 0.1)


@override_settings(PACKET_DEDUP_WINDOW_MINUTES=10)
class PacketDeduplicationTest(BasePacketSerializerTestCase):
Expand Down
18 changes: 18 additions & 0 deletions Meshflow/packets/urls_v3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""URL configuration for feeder API v3 (strict meshtastic_* node upsert wire)."""

from django.urls import include, path

from .views import ManagedNodeBotVersionView, NodeUpsertViewV3, PacketIngestView

urlpatterns = [
path(
"<int:node_id>/",
include(
[
path("ingest/", PacketIngestView.as_view(), name="meshtastic-packet-ingest-v3"),
path("nodes/", NodeUpsertViewV3.as_view(), name="meshtastic-node-upsert-v3"),
path("bot-version/", ManagedNodeBotVersionView.as_view(), name="meshtastic-bot-version-v3"),
]
),
),
]
22 changes: 17 additions & 5 deletions Meshflow/packets/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
TrafficManagementStatsPacket,
)

from .serializers import NodeSerializer, PacketIngestSerializer
from .serializers import NodeSerializer, NodeSerializerV3, PacketIngestSerializer
from .signals import (
air_quality_metrics_packet_received,
device_metrics_packet_received,
Expand Down Expand Up @@ -160,16 +160,16 @@ def post(self, request, node_id, format=None):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class NodeUpsertView(APIView):
class BaseNodeUpsertView(APIView):
"""
Meshtastic observed-node upsert for feeders (Node API key).

Creates or updates an ``ObservedNode`` row for Meshtastic ``node_id`` / ``node_id_str``.
MeshCore will use separate endpoints when implemented.
"""

authentication_classes = [NodeAPIKeyAuthentication]
permission_classes = [NodeAuthorizationPermission]
serializer_class = NodeSerializer

def post(self, request, node_id, format=None):
"""
Expand Down Expand Up @@ -233,10 +233,10 @@ def post(self, request, node_id, format=None):
q = ObservedNode.objects.filter(meshtastic_node_id=observed_node_id, protocol=Protocol.MESHTASTIC)
if q.exists():
node = q.first()
serializer = NodeSerializer(instance=node, data=request.data, partial=True)
serializer = self.serializer_class(instance=node, data=request.data, partial=True)
else:
node = None
serializer = NodeSerializer(data=request.data, partial=True)
serializer = self.serializer_class(data=request.data, partial=True)

if serializer.is_valid():
try:
Expand Down Expand Up @@ -270,6 +270,18 @@ def post(self, request, node_id, format=None):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class NodeUpsertView(BaseNodeUpsertView):
"""Node upsert on ``/api/packets/...`` (v2 wire: legacy field aliases allowed)."""

serializer_class = NodeSerializer


class NodeUpsertViewV3(BaseNodeUpsertView):
"""Node upsert on ``/api/v3/packets/...`` (strict ``meshtastic_*`` wire)."""

serializer_class = NodeSerializerV3


class ManagedNodeBotVersionSerializer(serializers.Serializer):
"""Body for feeder-reported meshflow-bot version."""

Expand Down
2 changes: 1 addition & 1 deletion docs/features/meshcore/phase-2-outstanding.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Items **skipped**, **incomplete**, or **discovered during Phase 2 / rename execu
- [ ] **Deploy order:** api #324 → ui #271 (bot #98 anytime). Migrations through `nodes.0041`, `constellations/0008`, `text_messages/0007`, `packets/0018`.
- [ ] **`tests/integration/`** full run after deploy (`pytest tests/integration/ -v`).
- [ ] **Release notes** — numeric observed-node bookmarks → UI redirect + api `by-meshtastic-id`.
- [ ] **Feeder upsert gap** — bot may send `location_source` / `channel_utilization`; api maps metrics from `meshtastic_*` only; optional `NodeSerializer` aliases.
- [x] **Feeder upsert gap** — v2 paths accept legacy `location_source` / `channel_utilization` / `air_util_tx` aliases; v3 at `/api/v3/packets/.../nodes/` (meshflow-api #339).
- [ ] **OpenAPI path param naming** — ingest/stats still use parameter name `node_id` (Meshtastic nodenum); cosmetic rename to `meshtastic_node_id` deferred.
- [ ] **Cursor rename index** — mark SP-05 `skipped` in `meshcore-rename-index.plan.md` when convenient (plan repo).

Expand Down
Loading