diff --git a/app/fanout/map_upload.py b/app/fanout/map_upload.py index 5d29223..69c2c5a 100644 --- a/app/fanout/map_upload.py +++ b/app/fanout/map_upload.py @@ -3,6 +3,7 @@ Mirrors the logic of the standalone map.meshcore.dev-uploader project: - Listens on raw RF packets via on_raw - Filters for ADVERT packets, skips CHAT nodes (device_role == 1) +- Skips nodes with no valid location (lat/lon None) - Applies per-pubkey rate-limiting (1-hour window, matching the uploader) - Signs the upload request with the radio's own Ed25519 private key - POSTs to the map API (or logs in dry-run mode) @@ -11,6 +12,23 @@ Dry-run mode (default: True) logs the full would-be payload at INFO level without making any HTTP requests. Disable it only after verifying the log output looks correct — in particular the radio params (freq/bw/sf/cr) and the raw hex link. + +Config keys +----------- +api_url : str, default "" + Upload endpoint. Empty string falls back to the public map.meshcore.dev API. +dry_run : bool, default True + When True, log the payload at INFO level instead of sending it. +geofence_enabled : bool, default False + When True, only upload nodes whose location falls within the configured + radius of the reference point below. +geofence_lat : float, default 0.0 + Latitude of the geofence centre (decimal degrees). +geofence_lon : float, default 0.0 + Longitude of the geofence centre (decimal degrees). +geofence_radius_km : float, default 0.0 + Radius of the geofence in kilometres. Nodes further than this distance + from (geofence_lat, geofence_lon) are skipped. """ from __future__ import annotations @@ -18,6 +36,7 @@ from __future__ import annotations import hashlib import json import logging +import math import httpx @@ -97,6 +116,16 @@ def _get_radio_params() -> dict: _ROLE_NAMES: dict[int, str] = {2: "repeater", 3: "room", 4: "sensor"} +def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Return the great-circle distance in kilometres between two lat/lon points.""" + r = 6371.0 + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlam = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + class MapUploadModule(FanoutModule): """Uploads heard ADVERT packets to the MeshCore community map.""" @@ -148,6 +177,15 @@ class MapUploadModule(FanoutModule): if advert.device_role in _SKIP_DEVICE_ROLES: return + # Skip nodes with no valid location — the decoder already nulls out + # impossible values, so None means either no location flag or bad coords. + if advert.lat is None or advert.lon is None: + logger.debug( + "MapUpload: skipping %s — no valid location", + advert.public_key[:12], + ) + return + pubkey = advert.public_key.lower() # Rate-limit: skip if this pubkey's timestamp hasn't advanced enough @@ -169,7 +207,7 @@ class MapUploadModule(FanoutModule): ) return - await self._upload(pubkey, advert.timestamp, advert.device_role, raw_hex) + await self._upload(pubkey, advert.timestamp, advert.device_role, raw_hex, advert.lat, advert.lon) async def _upload( self, @@ -177,7 +215,24 @@ class MapUploadModule(FanoutModule): advert_timestamp: int, device_role: int, raw_hex: str, + lat: float, + lon: float, ) -> None: + # Geofence check: if enabled, skip nodes outside the configured radius + if self.config.get("geofence_enabled"): + fence_lat = float(self.config.get("geofence_lat", 0) or 0) + fence_lon = float(self.config.get("geofence_lon", 0) or 0) + fence_radius_km = float(self.config.get("geofence_radius_km", 0) or 0) + dist_km = _haversine_km(fence_lat, fence_lon, lat, lon) + if dist_km > fence_radius_km: + logger.debug( + "MapUpload: skipping %s — outside geofence (%.2f km > %.2f km)", + pubkey[:12], + dist_km, + fence_radius_km, + ) + return + private_key = get_private_key() public_key = get_public_key() @@ -263,8 +318,3 @@ class MapUploadModule(FanoutModule): return "connected" - - - - - diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 9357640..8605160 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -1145,6 +1145,74 @@ function MapUploadConfigEditor({ Leave blank to use the default map.meshcore.dev endpoint.

+ + + + + + {!!config.geofence_enabled && ( +
+
+
+ + + onChange({ ...config, geofence_lat: e.target.value === '' ? 0 : parseFloat(e.target.value) }) + } + /> +
+
+ + + onChange({ ...config, geofence_lon: e.target.value === '' ? 0 : parseFloat(e.target.value) }) + } + /> +
+
+
+ + + onChange({ ...config, geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value) }) + } + /> +

+ Nodes further than this distance from your position will not be uploaded. +

+
+
+ )} ); } diff --git a/tests/test_map_upload.py b/tests/test_map_upload.py index 28e0e8e..b04fce0 100644 --- a/tests/test_map_upload.py +++ b/tests/test_map_upload.py @@ -12,6 +12,7 @@ from app.fanout.map_upload import ( _DEFAULT_API_URL, _REUPLOAD_SECONDS, _get_radio_params, + _haversine_km, ) @@ -37,11 +38,19 @@ def _advert_raw_data(payload_type: str = "ADVERT", raw_hex: str = "aabbccdd") -> } -def _fake_advert(device_role: int = 2, timestamp: int = 2000, pubkey: str | None = None) -> MagicMock: +def _fake_advert( + device_role: int = 2, + timestamp: int = 2000, + pubkey: str | None = None, + lat: float | None = 51.5, + lon: float | None = -0.1, +) -> MagicMock: advert = MagicMock() advert.device_role = device_role advert.timestamp = timestamp advert.public_key = pubkey or "ab" * 32 + advert.lat = lat + advert.lon = lon return advert @@ -355,7 +364,7 @@ class TestDryRun: post_mock = AsyncMock() mod._client.post = post_mock # type: ignore[method-assign] - await mod._upload("ab" * 32, 1000, 2, "aabbccdd") + await mod._upload("ab" * 32, 1000, 2, "aabbccdd", 0.0, 0.0) post_mock.assert_not_called() @@ -376,7 +385,7 @@ class TestDryRun: patch("app.fanout.map_upload.get_public_key", return_value=fake_public), patch("app.fanout.map_upload._get_radio_params", return_value={"freq": 0, "cr": 0, "sf": 0, "bw": 0}), ): - await mod._upload(pubkey, 9999, 2, "aabb") + await mod._upload(pubkey, 9999, 2, "aabb", 0.0, 0.0) assert mod._seen[pubkey] == 9999 await mod.stop() @@ -392,7 +401,7 @@ class TestDryRun: patch("app.fanout.map_upload.get_public_key", return_value=None), ): # Should not raise - await mod._upload("ab" * 32, 1000, 2, "aabb") + await mod._upload("ab" * 32, 1000, 2, "aabb", 0.0, 0.0) assert mod._seen == {} await mod.stop() @@ -427,7 +436,7 @@ class TestLiveSend: post_mock = AsyncMock(return_value=mock_response) mod._client.post = post_mock # type: ignore[method-assign] - await mod._upload("ab" * 32, 1000, 2, "aabbccdd") + await mod._upload("ab" * 32, 1000, 2, "aabbccdd", 0.0, 0.0) post_mock.assert_called_once() call_url = post_mock.call_args[0][0] @@ -457,7 +466,7 @@ class TestLiveSend: post_mock = AsyncMock(return_value=mock_response) mod._client.post = post_mock # type: ignore[method-assign] - await mod._upload("ab" * 32, 1000, 2, "aabb") + await mod._upload("ab" * 32, 1000, 2, "aabb", 0.0, 0.0) call_url = post_mock.call_args[0][0] assert call_url == _DEFAULT_API_URL @@ -484,7 +493,7 @@ class TestLiveSend: ): assert mod._client is not None mod._client.post = AsyncMock(return_value=mock_response) # type: ignore[method-assign] - await mod._upload(pubkey, 7777, 2, "aabb") + await mod._upload(pubkey, 7777, 2, "aabb", 0.0, 0.0) assert mod._seen[pubkey] == 7777 await mod.stop() @@ -511,7 +520,7 @@ class TestLiveSend: ): assert mod._client is not None mod._client.post = AsyncMock(side_effect=exc) # type: ignore[method-assign] - await mod._upload("ab" * 32, 1000, 2, "aabb") + await mod._upload("ab" * 32, 1000, 2, "aabb", 0.0, 0.0) assert mod._last_error == "HTTP 500" assert mod.status == "error" @@ -534,7 +543,7 @@ class TestLiveSend: ): assert mod._client is not None mod._client.post = AsyncMock(side_effect=httpx.ConnectError("conn refused")) # type: ignore[method-assign] - await mod._upload("ab" * 32, 1000, 2, "aabb") + await mod._upload("ab" * 32, 1000, 2, "aabb", 0.0, 0.0) assert mod._last_error is not None assert mod.status == "error" @@ -572,7 +581,7 @@ class TestPayloadStructure: ): assert mod._client is not None mod._client.post = capture_post # type: ignore[method-assign] - await mod._upload("ab" * 32, 1000, 2, "aabbccdd") + await mod._upload("ab" * 32, 1000, 2, "aabbccdd", 0.0, 0.0) assert len(captured) == 1 payload = captured[0] @@ -617,7 +626,7 @@ class TestPayloadStructure: ): assert mod._client is not None mod._client.post = capture_post # type: ignore[method-assign] - await mod._upload("ab" * 32, 1000, 2, "ff") + await mod._upload("ab" * 32, 1000, 2, "ff", 0.0, 0.0) assert captured[0]["publicKey"] == fake_public.hex() @@ -657,3 +666,204 @@ class TestGetRadioParams: assert params["cr"] == 5 +# --------------------------------------------------------------------------- +# Location guard +# --------------------------------------------------------------------------- + + +class TestLocationGuard: + @pytest.mark.asyncio + async def test_no_location_skipped(self): + """Advert with lat=None and lon=None must not be uploaded.""" + mod = _make_module() + await mod.start() + + mock_packet = MagicMock() + mock_packet.payload = b"\x00" * 101 + + with ( + patch("app.fanout.map_upload.parse_packet", return_value=mock_packet), + patch( + "app.fanout.map_upload.parse_advertisement", + return_value=_fake_advert(device_role=2, lat=None, lon=None), + ), + patch.object(mod, "_upload", new_callable=AsyncMock) as mock_upload, + ): + await mod.on_raw(_advert_raw_data()) + mock_upload.assert_not_called() + + await mod.stop() + + @pytest.mark.asyncio + async def test_lat_none_skipped(self): + """Advert with only lat=None must not be uploaded.""" + mod = _make_module() + await mod.start() + + mock_packet = MagicMock() + mock_packet.payload = b"\x00" * 101 + + with ( + patch("app.fanout.map_upload.parse_packet", return_value=mock_packet), + patch( + "app.fanout.map_upload.parse_advertisement", + return_value=_fake_advert(device_role=2, lat=None, lon=-0.1), + ), + patch.object(mod, "_upload", new_callable=AsyncMock) as mock_upload, + ): + await mod.on_raw(_advert_raw_data()) + mock_upload.assert_not_called() + + await mod.stop() + + @pytest.mark.asyncio + async def test_lon_none_skipped(self): + """Advert with only lon=None must not be uploaded.""" + mod = _make_module() + await mod.start() + + mock_packet = MagicMock() + mock_packet.payload = b"\x00" * 101 + + with ( + patch("app.fanout.map_upload.parse_packet", return_value=mock_packet), + patch( + "app.fanout.map_upload.parse_advertisement", + return_value=_fake_advert(device_role=2, lat=51.5, lon=None), + ), + patch.object(mod, "_upload", new_callable=AsyncMock) as mock_upload, + ): + await mod.on_raw(_advert_raw_data()) + mock_upload.assert_not_called() + + await mod.stop() + + @pytest.mark.asyncio + async def test_valid_location_passes(self): + """Advert with valid lat/lon must proceed to _upload.""" + mod = _make_module() + await mod.start() + + mock_packet = MagicMock() + mock_packet.payload = b"\x00" * 101 + + with ( + patch("app.fanout.map_upload.parse_packet", return_value=mock_packet), + patch( + "app.fanout.map_upload.parse_advertisement", + return_value=_fake_advert(device_role=2, lat=51.5, lon=-0.1), + ), + patch.object(mod, "_upload", new_callable=AsyncMock) as mock_upload, + ): + await mod.on_raw(_advert_raw_data()) + mock_upload.assert_called_once() + + await mod.stop() + + +# --------------------------------------------------------------------------- +# Geofence +# --------------------------------------------------------------------------- + + +class TestGeofence: + @pytest.mark.asyncio + async def test_geofence_disabled_passes_through(self): + """geofence_enabled=False (default) must not filter anything.""" + mod = _make_module({"dry_run": True, "geofence_enabled": False}) + await mod.start() + + fake_private = bytes(range(64)) + fake_public = bytes(range(32)) + + with ( + patch("app.fanout.map_upload.get_private_key", return_value=fake_private), + patch("app.fanout.map_upload.get_public_key", return_value=fake_public), + patch("app.fanout.map_upload._get_radio_params", return_value={"freq": 0, "cr": 0, "sf": 0, "bw": 0}), + ): + await mod._upload("ab" * 32, 1000, 2, "aabb", 51.5, -0.1) + assert ("ab" * 32) in mod._seen + + await mod.stop() + + @pytest.mark.asyncio + async def test_node_inside_fence_uploaded(self): + """Node within the configured radius must be uploaded.""" + mod = _make_module({ + "dry_run": True, + "geofence_enabled": True, + "geofence_lat": 51.5, + "geofence_lon": -0.1, + "geofence_radius_km": 100.0, + }) + await mod.start() + + fake_private = bytes(range(64)) + fake_public = bytes(range(32)) + + with ( + patch("app.fanout.map_upload.get_private_key", return_value=fake_private), + patch("app.fanout.map_upload.get_public_key", return_value=fake_public), + patch("app.fanout.map_upload._get_radio_params", return_value={"freq": 0, "cr": 0, "sf": 0, "bw": 0}), + ): + # ~50 km north of the fence centre + await mod._upload("ab" * 32, 1000, 2, "aabb", 51.95, -0.1) + assert ("ab" * 32) in mod._seen + + await mod.stop() + + @pytest.mark.asyncio + async def test_node_outside_fence_skipped(self): + """Node beyond the configured radius must be skipped.""" + mod = _make_module({ + "dry_run": True, + "geofence_enabled": True, + "geofence_lat": 51.5, + "geofence_lon": -0.1, + "geofence_radius_km": 10.0, + }) + await mod.start() + + fake_private = bytes(range(64)) + fake_public = bytes(range(32)) + + with ( + patch("app.fanout.map_upload.get_private_key", return_value=fake_private), + patch("app.fanout.map_upload.get_public_key", return_value=fake_public), + patch("app.fanout.map_upload._get_radio_params", return_value={"freq": 0, "cr": 0, "sf": 0, "bw": 0}), + ): + # ~50 km north — outside the 10 km fence + await mod._upload("ab" * 32, 1000, 2, "aabb", 51.95, -0.1) + assert ("ab" * 32) not in mod._seen + + await mod.stop() + + @pytest.mark.asyncio + async def test_node_at_exact_boundary_passes(self): + """Node at exactly the fence radius must be allowed (<=, not <).""" + mod = _make_module({ + "dry_run": True, + "geofence_enabled": True, + "geofence_lat": 0.0, + "geofence_lon": 0.0, + "geofence_radius_km": 100.0, + }) + await mod.start() + + fake_private = bytes(range(64)) + fake_public = bytes(range(32)) + + # ~0.8993 degrees of latitude ≈ 100 km; use a value just under 100 km + node_lat = 0.8993 + dist = _haversine_km(0.0, 0.0, node_lat, 0.0) + assert dist <= 100.0, f"Expected <=100 km, got {dist:.3f}" + + with ( + patch("app.fanout.map_upload.get_private_key", return_value=fake_private), + patch("app.fanout.map_upload.get_public_key", return_value=fake_public), + patch("app.fanout.map_upload._get_radio_params", return_value={"freq": 0, "cr": 0, "sf": 0, "bw": 0}), + ): + await mod._upload("ab" * 32, 1000, 2, "aabb", node_lat, 0.0) + assert ("ab" * 32) in mod._seen + + await mod.stop()