mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-26 13:01:35 +02:00
Add geofence option
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1145,6 +1145,74 @@ function MapUploadConfigEditor({
|
||||
Leave blank to use the default <code>map.meshcore.dev</code> endpoint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!config.geofence_enabled}
|
||||
onChange={(e) => onChange({ ...config, geofence_enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Enable Geofence</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only upload nodes whose location falls within the configured radius of your position.
|
||||
Helps exclude nodes with false or spoofed coordinates.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!!config.geofence_enabled && (
|
||||
<div className="space-y-3 pl-7">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-map-geofence-lat">My Latitude</Label>
|
||||
<Input
|
||||
id="fanout-map-geofence-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="e.g. 51.5"
|
||||
value={(config.geofence_lat as number | undefined) ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, geofence_lat: e.target.value === '' ? 0 : parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-map-geofence-lon">My Longitude</Label>
|
||||
<Input
|
||||
id="fanout-map-geofence-lon"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="e.g. -0.1"
|
||||
value={(config.geofence_lon as number | undefined) ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, geofence_lon: e.target.value === '' ? 0 : parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-map-geofence-radius">Radius (km)</Label>
|
||||
<Input
|
||||
id="fanout-map-geofence-radius"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="e.g. 100"
|
||||
value={(config.geofence_radius_km as number | undefined) ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nodes further than this distance from your position will not be uploaded.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+221
-11
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user