From 6eab75ec7e59f3855cc272c31c6310ce10561484 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 24 Mar 2026 11:09:16 -0400 Subject: [PATCH 01/11] Add Map Upload Integration and tests --- app/fanout/manager.py | 2 + app/fanout/map_upload.py | 270 +++++++ app/routers/fanout.py | 19 +- .../settings/SettingsFanoutSection.tsx | 98 ++- tests/test_fanout_integration.py | 110 +++ tests/test_map_upload.py | 659 ++++++++++++++++++ 6 files changed, 1155 insertions(+), 3 deletions(-) create mode 100644 app/fanout/map_upload.py create mode 100644 tests/test_map_upload.py diff --git a/app/fanout/manager.py b/app/fanout/manager.py index d58abbf..bf4ab16 100644 --- a/app/fanout/manager.py +++ b/app/fanout/manager.py @@ -21,6 +21,7 @@ def _register_module_types() -> None: return from app.fanout.apprise_mod import AppriseModule from app.fanout.bot import BotModule + from app.fanout.map_upload import MapUploadModule from app.fanout.mqtt_community import MqttCommunityModule from app.fanout.mqtt_private import MqttPrivateModule from app.fanout.sqs import SqsModule @@ -32,6 +33,7 @@ def _register_module_types() -> None: _MODULE_TYPES["webhook"] = WebhookModule _MODULE_TYPES["apprise"] = AppriseModule _MODULE_TYPES["sqs"] = SqsModule + _MODULE_TYPES["map_upload"] = MapUploadModule def _matches_filter(filter_value: Any, key: str) -> bool: diff --git a/app/fanout/map_upload.py b/app/fanout/map_upload.py new file mode 100644 index 0000000..5d29223 --- /dev/null +++ b/app/fanout/map_upload.py @@ -0,0 +1,270 @@ +"""Fanout module for uploading heard advert packets to map.meshcore.dev. + +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) +- 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) + +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. +""" + +from __future__ import annotations + +import hashlib +import json +import logging + +import httpx + +from app.decoder import parse_advertisement, parse_packet +from app.fanout.base import FanoutModule +from app.keystore import get_private_key, get_public_key +from app.services.radio_runtime import radio_runtime + +logger = logging.getLogger(__name__) + +_DEFAULT_API_URL = "https://map.meshcore.dev/api/v1/uploader/node" + +# Re-upload guard: skip re-uploading a pubkey seen within this window (AU parity) +_REUPLOAD_SECONDS = 3600 + +# Device role 1 = CHAT — skip these; repeaters (2) and rooms (3) are the map targets +_SKIP_DEVICE_ROLES = {1} + +# Ed25519 group order (L) +_L = 2**252 + 27742317777372353535851937790883648493 + + +def _ed25519_sign_expanded( + message: bytes, scalar: bytes, prefix: bytes, public_key: bytes +) -> bytes: + """Sign using MeshCore's expanded Ed25519 key format. + + MeshCore stores 64-byte keys as scalar(32) || prefix(32). Standard + Ed25519 libraries expect seed format and would re-SHA-512 the key, so + we perform the signing manually using the already-expanded key material. + + Mirrors the implementation in app/fanout/community_mqtt.py. + """ + import nacl.bindings + + r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L + R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little")) + k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L + s = (r + k * int.from_bytes(scalar, "little")) % _L + return R + s.to_bytes(32, "little") + + +def _get_radio_params() -> dict: + """Read radio frequency parameters from the connected radio's self_info. + + The standalone AU divides raw freq/bw values by 1000 before sending. + The meshcore Python library returns radio_freq and radio_bw at the same + scale as the JS library, so we apply the same /1000 division. + + IMPORTANT: verify the actual values in dry_run logs before enabling live + sends. The units reported by the Python library should be confirmed; if + the logged freq/bw look wrong (e.g. 0.915 instead of 915000), the + division factor here may need adjusting. + """ + try: + mc = radio_runtime.meshcore + if not mc: + return {"freq": 0, "cr": 0, "sf": 0, "bw": 0} + info = mc.self_info + if not isinstance(info, dict): + return {"freq": 0, "cr": 0, "sf": 0, "bw": 0} + freq = info.get("radio_freq", 0) or 0 + bw = info.get("radio_bw", 0) or 0 + sf = info.get("radio_sf", 0) or 0 + cr = info.get("radio_cr", 0) or 0 + return { + "freq": freq / 1000.0 if freq else 0, + "cr": cr, + "sf": sf, + "bw": bw / 1000.0 if bw else 0, + } + except Exception: + pass + return {"freq": 0, "cr": 0, "sf": 0, "bw": 0} + + +_ROLE_NAMES: dict[int, str] = {2: "repeater", 3: "room", 4: "sensor"} + + +class MapUploadModule(FanoutModule): + """Uploads heard ADVERT packets to the MeshCore community map.""" + + def __init__(self, config_id: str, config: dict, *, name: str = "") -> None: + super().__init__(config_id, config, name=name) + self._client: httpx.AsyncClient | None = None + self._last_error: str | None = None + # Per-pubkey rate limiting: pubkey_hex -> last_uploaded_advert_timestamp + self._seen: dict[str, int] = {} + + async def start(self) -> None: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(15.0)) + self._last_error = None + self._seen.clear() + + async def stop(self) -> None: + if self._client: + await self._client.aclose() + self._client = None + + async def on_raw(self, data: dict) -> None: + if data.get("payload_type") != "ADVERT": + return + + raw_hex = data.get("data", "") + if not raw_hex: + return + + try: + raw_bytes = bytes.fromhex(raw_hex) + except ValueError: + return + + packet_info = parse_packet(raw_bytes) + if packet_info is None: + return + + advert = parse_advertisement(packet_info.payload, raw_packet=raw_bytes) + if advert is None: + return + + # TODO: advert Ed25519 signature verification is skipped here. + # The radio has already validated the packet before passing it to RT, + # so re-verification is redundant in practice. If added, verify that + # nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes), + # advert.public_key_bytes) succeeds before proceeding. + + # Skip CHAT-type nodes (role 1) — map only shows repeaters (2) and rooms (3) + if advert.device_role in _SKIP_DEVICE_ROLES: + return + + pubkey = advert.public_key.lower() + + # Rate-limit: skip if this pubkey's timestamp hasn't advanced enough + last_seen = self._seen.get(pubkey) + if last_seen is not None: + if last_seen >= advert.timestamp: + logger.debug( + "MapUpload: skipping %s — possible replay (last=%d, advert=%d)", + pubkey[:12], + last_seen, + advert.timestamp, + ) + return + if advert.timestamp < last_seen + _REUPLOAD_SECONDS: + logger.debug( + "MapUpload: skipping %s — within 1-hr rate-limit window (delta=%ds)", + pubkey[:12], + advert.timestamp - last_seen, + ) + return + + await self._upload(pubkey, advert.timestamp, advert.device_role, raw_hex) + + async def _upload( + self, + pubkey: str, + advert_timestamp: int, + device_role: int, + raw_hex: str, + ) -> None: + private_key = get_private_key() + public_key = get_public_key() + + if private_key is None or public_key is None: + logger.warning( + "MapUpload: private key not available — cannot sign upload for %s. " + "Ensure radio firmware has ENABLE_PRIVATE_KEY_EXPORT=1.", + pubkey[:12], + ) + return + + api_url = str(self.config.get("api_url", "") or _DEFAULT_API_URL).strip() + dry_run = bool(self.config.get("dry_run", True)) + role_name = _ROLE_NAMES.get(device_role, f"role={device_role}") + + params = _get_radio_params() + upload_data = { + "params": params, + "links": [f"meshcore://{raw_hex}"], + } + + # Sign: SHA-256 the compact JSON, then Ed25519-sign the hash + json_str = json.dumps(upload_data, separators=(",", ":")) + data_hash = hashlib.sha256(json_str.encode()).digest() + scalar = private_key[:32] + prefix_bytes = private_key[32:] + signature = _ed25519_sign_expanded(data_hash, scalar, prefix_bytes, public_key) + + request_payload = { + "data": json_str, + "signature": signature.hex(), + "publicKey": public_key.hex(), + } + + if dry_run: + logger.info( + "MapUpload [DRY RUN] %s (%s) → would POST to %s\n payload: %s", + pubkey[:12], + role_name, + api_url, + json.dumps(request_payload, separators=(",", ":")), + ) + # Still update _seen so rate-limiting works during dry-run testing + self._seen[pubkey] = advert_timestamp + return + + if not self._client: + return + + try: + resp = await self._client.post( + api_url, + content=json.dumps(request_payload, separators=(",", ":")), + headers={"Content-Type": "application/json"}, + ) + resp.raise_for_status() + self._seen[pubkey] = advert_timestamp + self._last_error = None + logger.info( + "MapUpload: uploaded %s (%s) → HTTP %d", + pubkey[:12], + role_name, + resp.status_code, + ) + except httpx.HTTPStatusError as exc: + self._last_error = f"HTTP {exc.response.status_code}" + logger.warning( + "MapUpload: server returned %d for %s: %s", + exc.response.status_code, + pubkey[:12], + exc.response.text[:200], + ) + except httpx.RequestError as exc: + self._last_error = str(exc) + logger.warning("MapUpload: request error for %s: %s", pubkey[:12], exc) + + @property + def status(self) -> str: + if self._client is None: + return "disconnected" + if self._last_error: + return "error" + return "connected" + + + + + + + diff --git a/app/routers/fanout.py b/app/routers/fanout.py index d5c8cb7..da0c9c8 100644 --- a/app/routers/fanout.py +++ b/app/routers/fanout.py @@ -16,7 +16,7 @@ from app.repository.fanout import FanoutConfigRepository logger = logging.getLogger(__name__) router = APIRouter(prefix="/fanout", tags=["fanout"]) -_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs"} +_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "map_upload"} _IATA_RE = re.compile(r"^[A-Z]{3}$") _DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets" @@ -94,6 +94,8 @@ def _validate_and_normalize_config(config_type: str, config: dict) -> dict: _validate_apprise_config(normalized) elif config_type == "sqs": _validate_sqs_config(normalized) + elif config_type == "map_upload": + _validate_map_upload_config(normalized) return normalized @@ -295,10 +297,25 @@ def _validate_sqs_config(config: dict) -> None: ) +def _validate_map_upload_config(config: dict) -> None: + """Validate and normalize map_upload config blob.""" + api_url = str(config.get("api_url", "")).strip() + if api_url and not api_url.startswith(("http://", "https://")): + raise HTTPException( + status_code=400, + detail="api_url must start with http:// or https://", + ) + # Persist the cleaned value (empty string means use the module default) + config["api_url"] = api_url + config["dry_run"] = bool(config.get("dry_run", True)) + + def _enforce_scope(config_type: str, scope: dict) -> dict: """Enforce type-specific scope constraints. Returns normalized scope.""" if config_type == "mqtt_community": return {"messages": "none", "raw_packets": "all"} + if config_type == "map_upload": + return {"messages": "none", "raw_packets": "all"} if config_type == "bot": return {"messages": "all", "raw_packets": "none"} if config_type in ("webhook", "apprise"): diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index c055c46..9357640 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -21,8 +21,10 @@ const TYPE_LABELS: Record = { webhook: 'Webhook', apprise: 'Apprise', sqs: 'Amazon SQS', + map_upload: 'Map Upload', }; + const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets'; const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net'; const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net'; @@ -35,6 +37,7 @@ const DEFAULT_MESHRANK_TRANSPORT = 'tcp'; const DEFAULT_MESHRANK_AUTH_MODE = 'none'; const DEFAULT_MESHRANK_IATA = 'XYZ'; + function createCommunityConfigDefaults( overrides: Partial> = {} ): Record { @@ -100,7 +103,8 @@ type DraftType = | 'webhook' | 'apprise' | 'sqs' - | 'bot'; + | 'bot' + | 'map_upload'; type CreateIntegrationDefinition = { value: DraftType; @@ -284,6 +288,23 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ scope: { messages: 'all', raw_packets: 'none' }, }, }, + { + value: 'map_upload', + savedType: 'map_upload', + label: 'Map Upload', + section: 'Bulk Forwarding', + description: + 'Upload node positions to map.meshcore.dev or a compatible map API endpoint.', + defaultName: 'Map Upload', + nameMode: 'counted', + defaults: { + config: { + api_url: '', + dry_run: true, + }, + scope: { messages: 'none', raw_packets: 'all' }, + }, + }, ]; const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries( @@ -566,7 +587,9 @@ function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) { function getStatusLabel(status: string | undefined, type?: string) { if (status === 'connected') - return type === 'bot' || type === 'webhook' || type === 'apprise' ? 'Active' : 'Connected'; + return type === 'bot' || type === 'webhook' || type === 'apprise' || type === 'map_upload' + ? 'Active' + : 'Connected'; if (status === 'error') return 'Error'; if (status === 'disconnected') return 'Disconnected'; return 'Inactive'; @@ -1059,6 +1082,73 @@ function BotConfigEditor({ ); } +function MapUploadConfigEditor({ + config, + onChange, +}: { + config: Record; + onChange: (config: Record) => void; +}) { + const isDryRun = config.dry_run !== false; + return ( +
+

+ Automatically upload heard repeater and room advertisements to{' '} + + map.meshcore.dev + + . Requires the radio's private key to be available (firmware must have{' '} + ENABLE_PRIVATE_KEY_EXPORT=1). Only raw RF packets are shared — never + decrypted messages. +

+ +
+ Dry Run is {isDryRun ? 'ON' : 'OFF'}.{' '} + {isDryRun + ? 'No uploads will be sent. Check the backend logs to verify the payload looks correct before enabling live sends.' + : 'Live uploads are enabled. Each advert is rate-limited to once per hour per node.'} +
+ + + + + +
+ + onChange({ ...config, api_url: e.target.value })} + /> +

+ Leave blank to use the default map.meshcore.dev endpoint. +

+
+
+ ); +} + type ScopeMode = 'all' | 'none' | 'only' | 'except'; function getScopeMode(value: unknown): ScopeMode { @@ -1975,6 +2065,10 @@ export function SettingsFanoutSection({ /> )} + {detailType === 'map_upload' && ( + + )} +
diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py index 81f98fe..9d14400 100644 --- a/tests/test_fanout_integration.py +++ b/tests/test_fanout_integration.py @@ -1790,3 +1790,113 @@ class TestManagerRestartFailure: assert len(healthy.messages_received) == 1 assert len(dead.messages_received) == 0 + + +# --------------------------------------------------------------------------- +# MapUploadModule integration tests +# --------------------------------------------------------------------------- + + +class TestMapUploadIntegration: + """Integration tests: FanoutManager loads and dispatches to MapUploadModule.""" + + @pytest.mark.asyncio + async def test_map_upload_module_loaded_and_receives_raw(self, integration_db): + """Enabled map_upload config is loaded by the manager and its on_raw is called.""" + from unittest.mock import AsyncMock, MagicMock, patch + + cfg = await FanoutConfigRepository.create( + config_type="map_upload", + name="Map", + config={"dry_run": True, "api_url": ""}, + scope={"messages": "none", "raw_packets": "all"}, + enabled=True, + ) + + manager = FanoutManager() + await manager.load_from_db() + + assert cfg["id"] in manager._modules + module, scope = manager._modules[cfg["id"]] + assert scope == {"messages": "none", "raw_packets": "all"} + + # Raw ADVERT event should be dispatched to on_raw + advert_data = { + "payload_type": "ADVERT", + "data": "aabbccdd", + "timestamp": 1000, + "id": 1, + "observation_id": 1, + } + + with patch.object(module, "_upload", new_callable=AsyncMock) as mock_upload: + # Provide a parseable but minimal packet so on_raw gets past hex decode; + # parse_packet/parse_advertisement returning None is fine — on_raw silently exits + await manager.broadcast_raw(advert_data) + # Give the asyncio task a chance to run + import asyncio + await asyncio.sleep(0.05) + # _upload may or may not be called depending on parse result, but no exception + + await manager.stop_all() + + @pytest.mark.asyncio + async def test_map_upload_disabled_not_loaded(self, integration_db): + """Disabled map_upload config is not loaded by the manager.""" + await FanoutConfigRepository.create( + config_type="map_upload", + name="Map Disabled", + config={"dry_run": True, "api_url": ""}, + scope={"messages": "none", "raw_packets": "all"}, + enabled=False, + ) + + manager = FanoutManager() + await manager.load_from_db() + + assert len(manager._modules) == 0 + await manager.stop_all() + + @pytest.mark.asyncio + async def test_map_upload_does_not_receive_messages(self, integration_db): + """map_upload scope forces raw_packets only — message events must not reach it.""" + from unittest.mock import AsyncMock, patch + + cfg = await FanoutConfigRepository.create( + config_type="map_upload", + name="Map", + config={"dry_run": True, "api_url": ""}, + scope={"messages": "none", "raw_packets": "all"}, + enabled=True, + ) + + manager = FanoutManager() + await manager.load_from_db() + + assert cfg["id"] in manager._modules + module, _ = manager._modules[cfg["id"]] + + with patch.object(module, "on_message", new_callable=AsyncMock) as mock_msg: + await manager.broadcast_message({"type": "CHAN", "conversation_key": "k1", "text": "hi"}) + import asyncio + await asyncio.sleep(0.05) + mock_msg.assert_not_called() + + await manager.stop_all() + + @pytest.mark.asyncio + async def test_map_upload_scope_enforced_on_create(self, integration_db): + """Scope for map_upload is always fixed to raw_packets: all, messages: none.""" + # Even if a custom scope is passed, the router enforces the correct one. + # Here we verify the DB record has the enforced scope. + cfg = await FanoutConfigRepository.create( + config_type="map_upload", + name="Map", + config={"dry_run": True, "api_url": ""}, + scope={"messages": "all", "raw_packets": "none"}, # wrong, should be overridden by router + enabled=True, + ) + # The repository stores whatever the router passes — we test the router via HTTP + # in test_api.py; here we just verify the module works with the correct scope. + assert cfg["type"] == "map_upload" + diff --git a/tests/test_map_upload.py b/tests/test_map_upload.py new file mode 100644 index 0000000..28e0e8e --- /dev/null +++ b/tests/test_map_upload.py @@ -0,0 +1,659 @@ +"""Unit tests for the MapUploadModule fanout module.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.fanout.map_upload import ( + MapUploadModule, + _DEFAULT_API_URL, + _REUPLOAD_SECONDS, + _get_radio_params, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_module(config: dict | None = None) -> MapUploadModule: + cfg = {"dry_run": True, "api_url": ""} + if config: + cfg.update(config) + return MapUploadModule("test-id", cfg, name="Test Map Upload") + + +def _advert_raw_data(payload_type: str = "ADVERT", raw_hex: str = "aabbccdd") -> dict: + return { + "payload_type": payload_type, + "data": raw_hex, + "timestamp": 1000, + "id": 1, + "observation_id": 1, + } + + +def _fake_advert(device_role: int = 2, timestamp: int = 2000, pubkey: str | None = None) -> MagicMock: + advert = MagicMock() + advert.device_role = device_role + advert.timestamp = timestamp + advert.public_key = pubkey or "ab" * 32 + return advert + + +# --------------------------------------------------------------------------- +# Module lifecycle +# --------------------------------------------------------------------------- + + +class TestMapUploadLifecycle: + @pytest.mark.asyncio + async def test_start_creates_client(self): + mod = _make_module() + await mod.start() + assert mod._client is not None + assert mod.status == "connected" + await mod.stop() + + @pytest.mark.asyncio + async def test_stop_clears_client(self): + mod = _make_module() + await mod.start() + await mod.stop() + assert mod._client is None + assert mod.status == "disconnected" + + @pytest.mark.asyncio + async def test_start_clears_seen_table(self): + mod = _make_module() + mod._seen["somepubkey"] = 999 + await mod.start() + assert mod._seen == {} + await mod.stop() + + def test_status_error_when_last_error_set(self): + mod = _make_module() + mod._client = MagicMock() + mod._last_error = "HTTP 500" + assert mod.status == "error" + + +# --------------------------------------------------------------------------- +# on_raw filtering +# --------------------------------------------------------------------------- + + +class TestOnRawFiltering: + @pytest.mark.asyncio + async def test_non_advert_packet_ignored(self): + mod = _make_module() + await mod.start() + + with patch.object(mod, "_upload", new_callable=AsyncMock) as mock_upload: + await mod.on_raw(_advert_raw_data(payload_type="GROUP_TEXT")) + mock_upload.assert_not_called() + + await mod.stop() + + @pytest.mark.asyncio + async def test_empty_data_ignored(self): + mod = _make_module() + await mod.start() + + with patch.object(mod, "_upload", new_callable=AsyncMock) as mock_upload: + await mod.on_raw({"payload_type": "ADVERT", "data": ""}) + mock_upload.assert_not_called() + + await mod.stop() + + @pytest.mark.asyncio + async def test_invalid_hex_ignored(self): + mod = _make_module() + await mod.start() + + with patch.object(mod, "_upload", new_callable=AsyncMock) as mock_upload: + await mod.on_raw({"payload_type": "ADVERT", "data": "ZZZZ"}) + mock_upload.assert_not_called() + + await mod.stop() + + @pytest.mark.asyncio + async def test_parse_failure_ignored(self): + mod = _make_module() + await mod.start() + + with ( + patch("app.fanout.map_upload.parse_packet", return_value=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_advert_parse_failure_ignored(self): + mod = _make_module() + await mod.start() + + mock_packet = MagicMock() + mock_packet.payload = b"\x00" * 10 + + with ( + patch("app.fanout.map_upload.parse_packet", return_value=mock_packet), + patch("app.fanout.map_upload.parse_advertisement", return_value=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_chat_advert_skipped(self): + """device_role == 1 (CHAT) must be skipped.""" + 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=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_repeater_advert_processed(self): + """device_role == 2 (Repeater) must 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), + ), + 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() + + @pytest.mark.asyncio + async def test_room_advert_processed(self): + """device_role == 3 (Room) must 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=3), + ), + 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() + + +# --------------------------------------------------------------------------- +# Rate limiting +# --------------------------------------------------------------------------- + + +class TestRateLimiting: + @pytest.mark.asyncio + async def test_first_seen_pubkey_passes(self): + mod = _make_module() + await mod.start() + + pubkey = "ab" * 32 + 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, timestamp=5000, pubkey=pubkey), + ), + 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() + + @pytest.mark.asyncio + async def test_replay_skipped(self): + """Same or older timestamp should be skipped.""" + mod = _make_module() + await mod.start() + + pubkey = "ab" * 32 + mod._seen[pubkey] = 5000 # already uploaded at ts=5000 + + 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, timestamp=5000, pubkey=pubkey), + ), + 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_within_rate_limit_window_skipped(self): + """Newer timestamp but within 1-hr window should be skipped.""" + mod = _make_module() + await mod.start() + + pubkey = "ab" * 32 + last_ts = 5000 + mod._seen[pubkey] = last_ts + + mock_packet = MagicMock() + mock_packet.payload = b"\x00" * 101 + + # 30 minutes later — still within the 1-hour window + new_ts = last_ts + (_REUPLOAD_SECONDS // 2) + + 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, timestamp=new_ts, pubkey=pubkey), + ), + 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_after_rate_limit_window_passes(self): + """Timestamp beyond the 1-hr window should be uploaded again.""" + mod = _make_module() + await mod.start() + + pubkey = "ab" * 32 + last_ts = 5000 + mod._seen[pubkey] = last_ts + + mock_packet = MagicMock() + mock_packet.payload = b"\x00" * 101 + + new_ts = last_ts + _REUPLOAD_SECONDS + 1 + + 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, timestamp=new_ts, pubkey=pubkey), + ), + 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() + + +# --------------------------------------------------------------------------- +# Dry run behaviour +# --------------------------------------------------------------------------- + + +class TestDryRun: + @pytest.mark.asyncio + async def test_dry_run_logs_but_does_not_post(self): + """dry_run=True must log the payload but never call httpx.""" + mod = _make_module({"dry_run": True}) + 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": 915, "cr": 5, "sf": 10, "bw": 125}), + ): + assert mod._client is not None + post_mock = AsyncMock() + mod._client.post = post_mock # type: ignore[method-assign] + + await mod._upload("ab" * 32, 1000, 2, "aabbccdd") + + post_mock.assert_not_called() + + await mod.stop() + + @pytest.mark.asyncio + async def test_dry_run_updates_seen_table(self): + """dry_run still records the pubkey so rate-limiting works.""" + mod = _make_module({"dry_run": True}) + await mod.start() + + pubkey = "ab" * 32 + 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(pubkey, 9999, 2, "aabb") + assert mod._seen[pubkey] == 9999 + + await mod.stop() + + @pytest.mark.asyncio + async def test_dry_run_no_key_logs_warning_and_returns(self): + """If private key is missing, upload should log a warning and not crash.""" + mod = _make_module({"dry_run": True}) + await mod.start() + + with ( + patch("app.fanout.map_upload.get_private_key", return_value=None), + patch("app.fanout.map_upload.get_public_key", return_value=None), + ): + # Should not raise + await mod._upload("ab" * 32, 1000, 2, "aabb") + assert mod._seen == {} + + await mod.stop() + + +# --------------------------------------------------------------------------- +# Live send behaviour +# --------------------------------------------------------------------------- + + +class TestLiveSend: + @pytest.mark.asyncio + async def test_live_send_posts_to_api_url(self): + """dry_run=False should POST to the configured api_url.""" + custom_url = "https://custom.example.com/api/upload" + mod = _make_module({"dry_run": False, "api_url": custom_url}) + await mod.start() + + fake_private = bytes(range(64)) + fake_public = bytes(range(32)) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + + 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": 915, "cr": 5, "sf": 10, "bw": 125}), + ): + assert mod._client is not None + post_mock = AsyncMock(return_value=mock_response) + mod._client.post = post_mock # type: ignore[method-assign] + + await mod._upload("ab" * 32, 1000, 2, "aabbccdd") + + post_mock.assert_called_once() + call_url = post_mock.call_args[0][0] + assert call_url == custom_url + + await mod.stop() + + @pytest.mark.asyncio + async def test_live_send_defaults_to_map_url(self): + """Empty api_url should default to the map.meshcore.dev endpoint.""" + mod = _make_module({"dry_run": False, "api_url": ""}) + await mod.start() + + fake_private = bytes(range(64)) + fake_public = bytes(range(32)) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + + 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}), + ): + assert mod._client is not None + post_mock = AsyncMock(return_value=mock_response) + mod._client.post = post_mock # type: ignore[method-assign] + + await mod._upload("ab" * 32, 1000, 2, "aabb") + + call_url = post_mock.call_args[0][0] + assert call_url == _DEFAULT_API_URL + + await mod.stop() + + @pytest.mark.asyncio + async def test_live_send_updates_seen_on_success(self): + mod = _make_module({"dry_run": False}) + await mod.start() + + pubkey = "cd" * 32 + fake_private = bytes(range(64)) + fake_public = bytes(range(32)) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + + 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}), + ): + 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") + assert mod._seen[pubkey] == 7777 + + await mod.stop() + + @pytest.mark.asyncio + async def test_live_send_http_error_sets_last_error(self): + import httpx + + mod = _make_module({"dry_run": False}) + await mod.start() + + fake_private = bytes(range(64)) + fake_public = bytes(range(32)) + + error_response = MagicMock() + error_response.status_code = 500 + error_response.text = "Internal Server Error" + exc = httpx.HTTPStatusError("500", request=MagicMock(), response=error_response) + + 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}), + ): + 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") + assert mod._last_error == "HTTP 500" + assert mod.status == "error" + + await mod.stop() + + @pytest.mark.asyncio + async def test_live_send_request_error_sets_last_error(self): + import httpx + + mod = _make_module({"dry_run": 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}), + ): + 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") + assert mod._last_error is not None + assert mod.status == "error" + + await mod.stop() + + +# --------------------------------------------------------------------------- +# Payload structure +# --------------------------------------------------------------------------- + + +class TestPayloadStructure: + @pytest.mark.asyncio + async def test_request_payload_has_required_fields(self): + """The POST body must contain data, signature, and publicKey.""" + mod = _make_module({"dry_run": False}) + await mod.start() + + fake_private = bytes(range(64)) + fake_public = bytes(range(32)) + captured: list[dict] = [] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + + async def capture_post(url, *, content, headers): + captured.append(json.loads(content)) + return mock_response + + 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": 915, "cr": 5, "sf": 10, "bw": 125}), + ): + assert mod._client is not None + mod._client.post = capture_post # type: ignore[method-assign] + await mod._upload("ab" * 32, 1000, 2, "aabbccdd") + + assert len(captured) == 1 + payload = captured[0] + assert "data" in payload + assert "signature" in payload + assert "publicKey" in payload + + # data field should be parseable JSON with params and links + inner = json.loads(payload["data"]) + assert "params" in inner + assert "links" in inner + assert len(inner["links"]) == 1 + assert inner["links"][0] == "meshcore://aabbccdd" + + # links reference the raw hex as-is + assert inner["params"]["freq"] == 915 + assert inner["params"]["sf"] == 10 + + await mod.stop() + + @pytest.mark.asyncio + async def test_public_key_hex_in_payload(self): + mod = _make_module({"dry_run": False}) + await mod.start() + + fake_private = bytes(range(64)) + fake_public = bytes(range(32)) + captured: list[dict] = [] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + + async def capture_post(url, *, content, headers): + captured.append(json.loads(content)) + return mock_response + + 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}), + ): + assert mod._client is not None + mod._client.post = capture_post # type: ignore[method-assign] + await mod._upload("ab" * 32, 1000, 2, "ff") + + assert captured[0]["publicKey"] == fake_public.hex() + + await mod.stop() + + +# --------------------------------------------------------------------------- +# _get_radio_params +# --------------------------------------------------------------------------- + + +class TestGetRadioParams: + def test_returns_zeros_when_radio_not_connected(self): + with patch("app.fanout.map_upload.radio_runtime") as mock_rt: + mock_rt.meshcore = None + params = _get_radio_params() + assert params == {"freq": 0, "cr": 0, "sf": 0, "bw": 0} + + def test_returns_zeros_on_exception(self): + with patch("app.fanout.map_upload.radio_runtime", side_effect=Exception("boom")): + params = _get_radio_params() + assert params == {"freq": 0, "cr": 0, "sf": 0, "bw": 0} + + def test_divides_freq_and_bw_by_1000(self): + mock_rt = MagicMock() + mock_rt.meshcore.self_info = { + "radio_freq": 915000, + "radio_bw": 125000, + "radio_sf": 10, + "radio_cr": 5, + } + with patch("app.fanout.map_upload.radio_runtime", mock_rt): + params = _get_radio_params() + assert params["freq"] == 915.0 + assert params["bw"] == 125.0 + assert params["sf"] == 10 + assert params["cr"] == 5 + + From 7c4a244e054a1e3610b2f58d3659a6641604598f Mon Sep 17 00:00:00 2001 From: Kizniche Date: Tue, 24 Mar 2026 17:40:00 -0400 Subject: [PATCH 02/11] Add geofence option --- app/fanout/map_upload.py | 62 ++++- .../settings/SettingsFanoutSection.tsx | 68 +++++ tests/test_map_upload.py | 232 +++++++++++++++++- 3 files changed, 345 insertions(+), 17 deletions(-) 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() From f9ca35b3ae0104e90dc2813cc0d59a143a9119b9 Mon Sep 17 00:00:00 2001 From: Kizniche Date: Tue, 24 Mar 2026 18:53:28 -0400 Subject: [PATCH 03/11] Switch from block list to allow list, add test to ensure certain nodes are skipped, fix test --- app/fanout/map_upload.py | 19 +++++++++++-------- tests/test_map_upload.py | 30 ++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/app/fanout/map_upload.py b/app/fanout/map_upload.py index 69c2c5a..5dfce0f 100644 --- a/app/fanout/map_upload.py +++ b/app/fanout/map_upload.py @@ -2,7 +2,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) +- Filters for ADVERT packets, only processes repeaters (role 2) and rooms (role 3) - 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 @@ -52,8 +52,10 @@ _DEFAULT_API_URL = "https://map.meshcore.dev/api/v1/uploader/node" # Re-upload guard: skip re-uploading a pubkey seen within this window (AU parity) _REUPLOAD_SECONDS = 3600 -# Device role 1 = CHAT — skip these; repeaters (2) and rooms (3) are the map targets -_SKIP_DEVICE_ROLES = {1} +# Only upload repeaters (2) and rooms (3). Any other role — including future +# roles not yet defined — is rejected. An allowlist is used rather than a +# blocklist so that new roles cannot accidentally start populating the map. +_ALLOWED_DEVICE_ROLES = {2, 3} # Ed25519 group order (L) _L = 2**252 + 27742317777372353535851937790883648493 @@ -108,12 +110,12 @@ def _get_radio_params() -> dict: "sf": sf, "bw": bw / 1000.0 if bw else 0, } - except Exception: - pass + except Exception as exc: + logger.debug("MapUpload: could not read radio params: %s", exc) return {"freq": 0, "cr": 0, "sf": 0, "bw": 0} -_ROLE_NAMES: dict[int, str] = {2: "repeater", 3: "room", 4: "sensor"} +_ROLE_NAMES: dict[int, str] = {2: "repeater", 3: "room"} def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: @@ -145,6 +147,7 @@ class MapUploadModule(FanoutModule): if self._client: await self._client.aclose() self._client = None + self._last_error = None async def on_raw(self, data: dict) -> None: if data.get("payload_type") != "ADVERT": @@ -173,8 +176,8 @@ class MapUploadModule(FanoutModule): # nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes), # advert.public_key_bytes) succeeds before proceeding. - # Skip CHAT-type nodes (role 1) — map only shows repeaters (2) and rooms (3) - if advert.device_role in _SKIP_DEVICE_ROLES: + # Only process repeaters (2) and rooms (3) — any other role is rejected + if advert.device_role not in _ALLOWED_DEVICE_ROLES: return # Skip nodes with no valid location — the decoder already nulls out diff --git a/tests/test_map_upload.py b/tests/test_map_upload.py index b04fce0..5c7d130 100644 --- a/tests/test_map_upload.py +++ b/tests/test_map_upload.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest @@ -72,8 +72,10 @@ class TestMapUploadLifecycle: async def test_stop_clears_client(self): mod = _make_module() await mod.start() + mod._last_error = "HTTP 500" # simulate a prior error await mod.stop() assert mod._client is None + assert mod._last_error is None assert mod.status == "disconnected" @pytest.mark.asyncio @@ -184,6 +186,28 @@ class TestOnRawFiltering: await mod.stop() + @pytest.mark.asyncio + async def test_sensor_advert_skipped(self): + """device_role == 4 (Sensor) must be skipped.""" + 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=4), + ), + 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_repeater_advert_processed(self): """device_role == 2 (Repeater) must be uploaded.""" @@ -646,7 +670,9 @@ class TestGetRadioParams: assert params == {"freq": 0, "cr": 0, "sf": 0, "bw": 0} def test_returns_zeros_on_exception(self): - with patch("app.fanout.map_upload.radio_runtime", side_effect=Exception("boom")): + mock_rt = MagicMock() + type(mock_rt).meshcore = PropertyMock(side_effect=Exception("boom")) + with patch("app.fanout.map_upload.radio_runtime", mock_rt): params = _get_radio_params() assert params == {"freq": 0, "cr": 0, "sf": 0, "bw": 0} From bab1693c82a3c40656a3d434b2fab7776979c358 Mon Sep 17 00:00:00 2001 From: Kizniche Date: Wed, 25 Mar 2026 18:39:27 -0400 Subject: [PATCH 04/11] Fix freq and BW values, add geofence calc to dry run log --- app/fanout/map_upload.py | 31 +++++----- .../settings/SettingsFanoutSection.tsx | 4 +- tests/test_map_upload.py | 62 ++++++++++++++++++- 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/app/fanout/map_upload.py b/app/fanout/map_upload.py index 5dfce0f..9ca2d4b 100644 --- a/app/fanout/map_upload.py +++ b/app/fanout/map_upload.py @@ -84,14 +84,10 @@ def _ed25519_sign_expanded( def _get_radio_params() -> dict: """Read radio frequency parameters from the connected radio's self_info. - The standalone AU divides raw freq/bw values by 1000 before sending. - The meshcore Python library returns radio_freq and radio_bw at the same - scale as the JS library, so we apply the same /1000 division. - - IMPORTANT: verify the actual values in dry_run logs before enabling live - sends. The units reported by the Python library should be confirmed; if - the logged freq/bw look wrong (e.g. 0.915 instead of 915000), the - division factor here may need adjusting. + The Python meshcore library returns radio_freq in MHz (e.g. 910.525) and + radio_bw in kHz (e.g. 62.5). These are exactly the units the map API + expects, matching what the JS reference uploader produces after its own + /1000 division on raw integer values. No further scaling is applied here. """ try: mc = radio_runtime.meshcore @@ -105,10 +101,10 @@ def _get_radio_params() -> dict: sf = info.get("radio_sf", 0) or 0 cr = info.get("radio_cr", 0) or 0 return { - "freq": freq / 1000.0 if freq else 0, + "freq": freq, "cr": cr, "sf": sf, - "bw": bw / 1000.0 if bw else 0, + "bw": bw, } except Exception as exc: logger.debug("MapUpload: could not read radio params: %s", exc) @@ -222,16 +218,17 @@ class MapUploadModule(FanoutModule): lon: float, ) -> None: # Geofence check: if enabled, skip nodes outside the configured radius + geofence_dist_km: float | None = None 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: + geofence_dist_km = _haversine_km(fence_lat, fence_lon, lat, lon) + if geofence_dist_km > fence_radius_km: logger.debug( "MapUpload: skipping %s — outside geofence (%.2f km > %.2f km)", pubkey[:12], - dist_km, + geofence_dist_km, fence_radius_km, ) return @@ -271,10 +268,16 @@ class MapUploadModule(FanoutModule): } if dry_run: + geofence_note = ( + f" | geofence: {geofence_dist_km:.2f} km from observer" + if geofence_dist_km is not None + else "" + ) logger.info( - "MapUpload [DRY RUN] %s (%s) → would POST to %s\n payload: %s", + "MapUpload [DRY RUN] %s (%s)%s → would POST to %s\n payload: %s", pubkey[:12], role_name, + geofence_note, api_url, json.dumps(request_payload, separators=(",", ":")), ) diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 8605160..60f50db 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -294,7 +294,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ label: 'Map Upload', section: 'Bulk Forwarding', description: - 'Upload node positions to map.meshcore.dev or a compatible map API endpoint.', + 'Upload repeaters and room servers to map.meshcore.dev or a compatible map API endpoint.', defaultName: 'Map Upload', nameMode: 'counted', defaults: { @@ -1093,7 +1093,7 @@ function MapUploadConfigEditor({ return (

- Automatically upload heard repeater and room advertisements to{' '} + Automatically upload heard repeater and room server advertisements to{' '} Date: Wed, 25 Mar 2026 19:06:33 -0400 Subject: [PATCH 05/11] Fix issues identified in failing checks --- .../settings/SettingsFanoutSection.tsx | 17 ++++++++++++----- tests/test_fanout_integration.py | 4 ++-- tests/test_map_upload.py | 5 +---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 60f50db..fd0c17e 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -24,7 +24,6 @@ const TYPE_LABELS: Record = { map_upload: 'Map Upload', }; - const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets'; const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net'; const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net'; @@ -37,7 +36,6 @@ const DEFAULT_MESHRANK_TRANSPORT = 'tcp'; const DEFAULT_MESHRANK_AUTH_MODE = 'none'; const DEFAULT_MESHRANK_IATA = 'XYZ'; - function createCommunityConfigDefaults( overrides: Partial> = {} ): Record { @@ -1176,7 +1174,10 @@ function MapUploadConfigEditor({ 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) }) + onChange({ + ...config, + geofence_lat: e.target.value === '' ? 0 : parseFloat(e.target.value), + }) } />

@@ -1189,7 +1190,10 @@ function MapUploadConfigEditor({ 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) }) + onChange({ + ...config, + geofence_lon: e.target.value === '' ? 0 : parseFloat(e.target.value), + }) } /> @@ -1204,7 +1208,10 @@ function MapUploadConfigEditor({ 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) }) + onChange({ + ...config, + geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value), + }) } />

diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py index 9d14400..ae888be 100644 --- a/tests/test_fanout_integration.py +++ b/tests/test_fanout_integration.py @@ -1803,7 +1803,7 @@ class TestMapUploadIntegration: @pytest.mark.asyncio async def test_map_upload_module_loaded_and_receives_raw(self, integration_db): """Enabled map_upload config is loaded by the manager and its on_raw is called.""" - from unittest.mock import AsyncMock, MagicMock, patch + from unittest.mock import AsyncMock, patch cfg = await FanoutConfigRepository.create( config_type="map_upload", @@ -1829,7 +1829,7 @@ class TestMapUploadIntegration: "observation_id": 1, } - with patch.object(module, "_upload", new_callable=AsyncMock) as mock_upload: + with patch.object(module, "_upload", new_callable=AsyncMock): # Provide a parseable but minimal packet so on_raw gets past hex decode; # parse_packet/parse_advertisement returning None is fine — on_raw silently exits await manager.broadcast_raw(advert_data) diff --git a/tests/test_map_upload.py b/tests/test_map_upload.py index fceb3b3..e7cdf9b 100644 --- a/tests/test_map_upload.py +++ b/tests/test_map_upload.py @@ -8,14 +8,13 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest from app.fanout.map_upload import ( - MapUploadModule, _DEFAULT_API_URL, _REUPLOAD_SECONDS, + MapUploadModule, _get_radio_params, _haversine_km, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -898,8 +897,6 @@ class TestGeofence: @pytest.mark.asyncio async def test_dry_run_geofence_logs_distance(self): """dry_run + geofence_enabled must include the calculated distance in the log line.""" - import logging - mod = _make_module({ "dry_run": True, "geofence_enabled": True, From efeb047116b20a4312877e94c6b41b7103960f9b Mon Sep 17 00:00:00 2001 From: Kizniche Date: Thu, 26 Mar 2026 19:55:30 -0400 Subject: [PATCH 06/11] Switching to using radio lat/lon, rename Community MQTT to Community Sharing, update AGENTS_fanout.md --- app/fanout/AGENTS_fanout.md | 14 ++ app/fanout/map_upload.py | 51 ++++-- .../settings/SettingsFanoutSection.tsx | 86 ++++----- frontend/src/test/fanoutSection.test.tsx | 19 +- tests/test_map_upload.py | 165 ++++++++++++------ 5 files changed, 225 insertions(+), 110 deletions(-) diff --git a/app/fanout/AGENTS_fanout.md b/app/fanout/AGENTS_fanout.md index 30a4069..56280ac 100644 --- a/app/fanout/AGENTS_fanout.md +++ b/app/fanout/AGENTS_fanout.md @@ -89,6 +89,19 @@ Amazon SQS delivery. Config blob: - Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}` - Supports both decoded messages and raw packets via normal scope selection +### map_upload (map_upload.py) +Uploads heard repeater and room-server advertisements to map.meshcore.dev. Config blob: +- `api_url` (optional, default `""`) — upload endpoint; empty falls back to the public map.meshcore.dev API +- `dry_run` (bool, default `true`) — when true, logs the payload at INFO level without sending +- `geofence_enabled` (bool, default `false`) — when true, only uploads nodes within `geofence_radius_km` of the radio's own configured lat/lon +- `geofence_radius_km` (float, default `0`) — filter radius in kilometres + +Geofence notes: +- The reference center is always the radio's own `adv_lat`/`adv_lon` from `radio_runtime.meshcore.self_info`, read **live at upload time** — no lat/lon is stored in the fanout config itself. +- If the radio's lat/lon is `(0, 0)` or the radio is not connected, the geofence check is silently skipped so uploads continue normally until coordinates are configured. +- Requires the radio to have `ENABLE_PRIVATE_KEY_EXPORT=1` firmware to sign uploads. +- Scope is always `{"messages": "none", "raw_packets": "all"}` — only raw RF packets are processed. + ## Adding a New Integration Type ### Step-by-step checklist @@ -291,6 +304,7 @@ Migrations: - `app/fanout/webhook.py` — Webhook fanout module - `app/fanout/apprise_mod.py` — Apprise fanout module - `app/fanout/sqs.py` — Amazon SQS fanout module +- `app/fanout/map_upload.py` — Map Upload fanout module - `app/repository/fanout.py` — Database CRUD - `app/routers/fanout.py` — REST API - `app/websocket.py` — `broadcast_event()` dispatches to fanout diff --git a/app/fanout/map_upload.py b/app/fanout/map_upload.py index 9ca2d4b..9ab0c88 100644 --- a/app/fanout/map_upload.py +++ b/app/fanout/map_upload.py @@ -20,15 +20,14 @@ api_url : str, default "" 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). + When True, only upload nodes whose location falls within geofence_radius_km of + the radio's own configured latitude/longitude (read live from the radio at upload + time — no lat/lon is stored in this config). When the radio's lat/lon is not set + (0, 0) or unavailable, the geofence check is silently skipped so uploads continue + normally until coordinates are configured. 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 the radio's own position are skipped. """ from __future__ import annotations @@ -217,21 +216,39 @@ class MapUploadModule(FanoutModule): lat: float, lon: float, ) -> None: - # Geofence check: if enabled, skip nodes outside the configured radius + # Geofence check: if enabled, skip nodes outside the configured radius. + # The reference center is the radio's own lat/lon read live from self_info — + # no coordinates are stored in the fanout config. If the radio lat/lon is + # (0, 0) or unavailable the check is skipped transparently so uploads + # continue normally until the operator sets coordinates in radio settings. geofence_dist_km: float | None = None 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) - geofence_dist_km = _haversine_km(fence_lat, fence_lon, lat, lon) - if geofence_dist_km > fence_radius_km: + try: + mc = radio_runtime.meshcore + sinfo = mc.self_info if mc else None + fence_lat = float((sinfo or {}).get("adv_lat", 0) or 0) + fence_lon = float((sinfo or {}).get("adv_lon", 0) or 0) + except Exception as exc: + logger.debug("MapUpload: could not read radio lat/lon for geofence: %s", exc) + fence_lat = 0.0 + fence_lon = 0.0 + + if fence_lat == 0.0 and fence_lon == 0.0: logger.debug( - "MapUpload: skipping %s — outside geofence (%.2f km > %.2f km)", + "MapUpload: geofence skipped for %s — radio lat/lon not configured", pubkey[:12], - geofence_dist_km, - fence_radius_km, ) - return + else: + fence_radius_km = float(self.config.get("geofence_radius_km", 0) or 0) + geofence_dist_km = _haversine_km(fence_lat, fence_lon, lat, lon) + if geofence_dist_km > fence_radius_km: + logger.debug( + "MapUpload: skipping %s — outside geofence (%.2f km > %.2f km)", + pubkey[:12], + geofence_dist_km, + fence_radius_km, + ) + return private_key = get_private_key() public_key = get_public_key() diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index fd0c17e..ec1ffd4 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -16,7 +16,7 @@ const BotCodeEditor = lazy(() => const TYPE_LABELS: Record = { mqtt_private: 'Private MQTT', - mqtt_community: 'Community MQTT', + mqtt_community: 'Community Sharing', bot: 'Python Bot', webhook: 'Webhook', apprise: 'Apprise', @@ -145,7 +145,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ value: 'mqtt_community', savedType: 'mqtt_community', label: 'Community MQTT/meshcoretomqtt', - section: 'Community MQTT', + section: 'Community Sharing', description: 'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.', defaultName: 'Community MQTT', @@ -159,7 +159,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ value: 'mqtt_community_meshrank', savedType: 'mqtt_community', label: 'MeshRank', - section: 'Community MQTT', + section: 'Community Sharing', description: 'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.', defaultName: 'MeshRank', @@ -182,7 +182,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ value: 'mqtt_community_letsmesh_us', savedType: 'mqtt_community', label: 'LetsMesh (US)', - section: 'Community MQTT', + section: 'Community Sharing', description: 'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.', defaultName: 'LetsMesh (US)', @@ -199,7 +199,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ value: 'mqtt_community_letsmesh_eu', savedType: 'mqtt_community', label: 'LetsMesh (EU)', - section: 'Community MQTT', + section: 'Community Sharing', description: 'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.', defaultName: 'LetsMesh (EU)', @@ -290,7 +290,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ value: 'map_upload', savedType: 'map_upload', label: 'Map Upload', - section: 'Bulk Forwarding', + section: 'Community Sharing', description: 'Upload repeaters and room servers to map.meshcore.dev or a compatible map API endpoint.', defaultName: 'Map Upload', @@ -1088,6 +1088,25 @@ function MapUploadConfigEditor({ onChange: (config: Record) => void; }) { const isDryRun = config.dry_run !== false; + const [radioLat, setRadioLat] = useState(null); + const [radioLon, setRadioLon] = useState(null); + + useEffect(() => { + api + .getRadioConfig() + .then((rc) => { + setRadioLat(rc.lat ?? 0); + setRadioLon(rc.lon ?? 0); + }) + .catch(() => { + setRadioLat(0); + setRadioLon(0); + }); + }, []); + + const radioLatLonConfigured = + radioLat !== null && radioLon !== null && !(radioLat === 0 && radioLon === 0); + return (

@@ -1156,48 +1175,31 @@ function MapUploadConfigEditor({

Enable Geofence

- Only upload nodes whose location falls within the configured radius of your position. - Helps exclude nodes with false or spoofed coordinates. + Only upload nodes whose location falls within the configured radius of your radio's + own position. Helps exclude nodes with false or spoofed coordinates. Uses the + latitude/longitude set in Radio Settings.

{!!config.geofence_enabled && (
-
-
- - - onChange({ - ...config, - geofence_lat: e.target.value === '' ? 0 : parseFloat(e.target.value), - }) - } - /> + {!radioLatLonConfigured && ( +
+ Your radio does not currently have a latitude/longitude configured. Geofencing will be + silently skipped until coordinates are set in{' '} + Settings → Radio → Location.
-
- - - onChange({ - ...config, - geofence_lon: e.target.value === '' ? 0 : parseFloat(e.target.value), - }) - } - /> -
-
+ )} + {radioLatLonConfigured && ( +

+ Using radio position{' '} + + {radioLat?.toFixed(5)}, {radioLon?.toFixed(5)} + {' '} + as the geofence center. Update coordinates in Radio Settings to move the center. +

+ )}

- Nodes further than this distance from your position will not be uploaded. + Nodes further than this distance from your radio's position will not be uploaded.

diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index 4cd447b..8739553 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -12,6 +12,7 @@ vi.mock('../api', () => ({ deleteFanoutConfig: vi.fn(), getChannels: vi.fn(), getContacts: vi.fn(), + getRadioConfig: vi.fn(), }, })); @@ -96,6 +97,17 @@ beforeEach(() => { mockedApi.getFanoutConfigs.mockResolvedValue([]); mockedApi.getChannels.mockResolvedValue([]); mockedApi.getContacts.mockResolvedValue([]); + mockedApi.getRadioConfig.mockResolvedValue({ + public_key: 'aa'.repeat(32), + name: 'TestNode', + lat: 0, + lon: 0, + tx_power: 17, + max_tx_power: 22, + radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, + path_hash_mode: 0, + path_hash_mode_supported: false, + }); }); describe('SettingsFanoutSection', () => { @@ -106,7 +118,7 @@ describe('SettingsFanoutSection', () => { const optionButtons = within(dialog) .getAllByRole('button') .filter((button) => button.hasAttribute('aria-pressed')); - expect(optionButtons).toHaveLength(9); + expect(optionButtons).toHaveLength(10); expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument(); expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument(); expect( @@ -138,6 +150,9 @@ describe('SettingsFanoutSection', () => { expect( within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') }) ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: startsWithAccessibleName('Map Upload') }) + ).toBeInTheDocument(); expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument(); const genericCommunityIndex = optionButtons.findIndex((button) => @@ -916,7 +931,7 @@ describe('SettingsFanoutSection', () => { await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); - expect(screen.getByLabelText('Name')).toHaveValue('Community MQTT #1'); + expect(screen.getByLabelText('Name')).toHaveValue('Community Sharing #1'); expect(screen.getByLabelText('Broker Host')).toBeInTheDocument(); expect(screen.getByLabelText('Authentication')).toBeInTheDocument(); expect(screen.getByLabelText('Packet Topic Template')).toBeInTheDocument(); diff --git a/tests/test_map_upload.py b/tests/test_map_upload.py index e7cdf9b..ee98835 100644 --- a/tests/test_map_upload.py +++ b/tests/test_map_upload.py @@ -791,6 +791,18 @@ class TestLocationGuard: # Geofence # --------------------------------------------------------------------------- +# Shared helpers for radio_runtime patching in geofence tests +_FAKE_PRIVATE = bytes(range(64)) +_FAKE_PUBLIC = bytes(range(32)) +_FAKE_RADIO_PARAMS = {"freq": 0, "cr": 0, "sf": 0, "bw": 0} + + +def _mock_radio_runtime_with_location(lat: float, lon: float): + """Return a context-manager mock for radio_runtime with the given lat/lon.""" + mock_rt = MagicMock() + mock_rt.meshcore.self_info = {"adv_lat": lat, "adv_lon": lon} + return patch("app.fanout.map_upload.radio_runtime", mock_rt) + class TestGeofence: @pytest.mark.asyncio @@ -799,13 +811,10 @@ class TestGeofence: 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}), + 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=_FAKE_RADIO_PARAMS), ): await mod._upload("ab" * 32, 1000, 2, "aabb", 51.5, -0.1) assert ("ab" * 32) in mod._seen @@ -818,21 +827,17 @@ class TestGeofence: 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}), + _mock_radio_runtime_with_location(51.5, -0.1), + 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=_FAKE_RADIO_PARAMS), ): - # ~50 km north of the fence centre + # ~50 km north of the fence center await mod._upload("ab" * 32, 1000, 2, "aabb", 51.95, -0.1) assert ("ab" * 32) in mod._seen @@ -844,19 +849,15 @@ class TestGeofence: 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}), + _mock_radio_runtime_with_location(51.5, -0.1), + 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=_FAKE_RADIO_PARAMS), ): # ~50 km north — outside the 10 km fence await mod._upload("ab" * 32, 1000, 2, "aabb", 51.95, -0.1) @@ -870,26 +871,72 @@ class TestGeofence: 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) + # Use a non-zero center so it's not treated as "not configured". + # Purely latitudinal haversine distance is origin-independent, so + # 0.8993° from (1.0, 0.0) gives the same ~100 km as from (0.0, 0.0). + fence_lat, fence_lon = 1.0, 0.0 + node_lat = fence_lat + 0.8993 + dist = _haversine_km(fence_lat, fence_lon, node_lat, fence_lon) 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}), + _mock_radio_runtime_with_location(fence_lat, fence_lon), + 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=_FAKE_RADIO_PARAMS), ): - await mod._upload("ab" * 32, 1000, 2, "aabb", node_lat, 0.0) + await mod._upload("ab" * 32, 1000, 2, "aabb", node_lat, fence_lon) + assert ("ab" * 32) in mod._seen + + await mod.stop() + + @pytest.mark.asyncio + async def test_geofence_skipped_when_lat_lon_zero(self): + """geofence_enabled=True but radio (0, 0) → upload proceeds (geofence silently skipped).""" + mod = _make_module({ + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": 10.0, + }) + await mod.start() + + # Radio is at (0, 0) — treated as "not configured"; all nodes pass through. + with ( + _mock_radio_runtime_with_location(0.0, 0.0), + 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=_FAKE_RADIO_PARAMS), + ): + # This node is many thousands of km from (0,0) — would be filtered if fence active. + 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_geofence_skipped_when_radio_unavailable(self): + """geofence_enabled=True but radio is not connected → upload proceeds.""" + mod = _make_module({ + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": 10.0, + }) + await mod.start() + + mock_rt = MagicMock() + mock_rt.meshcore = None # radio not connected + + with ( + patch("app.fanout.map_upload.radio_runtime", mock_rt), + 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=_FAKE_RADIO_PARAMS), + ): + await mod._upload("ab" * 32, 1000, 2, "aabb", 51.5, -0.1) assert ("ab" * 32) in mod._seen await mod.stop() @@ -900,19 +947,15 @@ class TestGeofence: 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}), + _mock_radio_runtime_with_location(51.5, -0.1), + 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=_FAKE_RADIO_PARAMS), ): with patch("app.fanout.map_upload.logger") as mock_logger: # ~50 km north — inside the fence @@ -930,13 +973,10 @@ class TestGeofence: 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}), + 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=_FAKE_RADIO_PARAMS), ): with patch("app.fanout.map_upload.logger") as mock_logger: await mod._upload("ab" * 32, 1000, 2, "aabb", 51.5, -0.1) @@ -946,3 +986,30 @@ class TestGeofence: await mod.stop() + @pytest.mark.asyncio + async def test_dry_run_geofence_no_distance_when_lat_lon_zero(self): + """dry_run + geofence_enabled but radio (0, 0) → no distance note in log (skipped).""" + mod = _make_module({ + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": 100.0, + }) + await mod.start() + + with ( + _mock_radio_runtime_with_location(0.0, 0.0), + 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=_FAKE_RADIO_PARAMS), + ): + with patch("app.fanout.map_upload.logger") as mock_logger: + await mod._upload("ab" * 32, 1000, 2, "aabb", 51.5, -0.1) + # Upload still happens (seen table updated), but log should not mention geofence distance + assert ("ab" * 32) in mod._seen + log_calls = mock_logger.info.call_args_list + for call in log_calls: + msg = call[0][0] % call[0][1:] if call[0][1:] else call[0][0] + assert "km from observer" not in msg + + await mod.stop() + From 53f122e503d6ee19ce766125f40331b73ac0686d Mon Sep 17 00:00:00 2001 From: Kizniche Date: Thu, 26 Mar 2026 20:08:42 -0400 Subject: [PATCH 07/11] formatting changes to satisfy check --- app/fanout/map_upload.py | 6 +- tests/test_fanout_integration.py | 12 ++- tests/test_map_upload.py | 130 ++++++++++++++++++++----------- 3 files changed, 97 insertions(+), 51 deletions(-) diff --git a/app/fanout/map_upload.py b/app/fanout/map_upload.py index 9ab0c88..5c23887 100644 --- a/app/fanout/map_upload.py +++ b/app/fanout/map_upload.py @@ -205,7 +205,9 @@ class MapUploadModule(FanoutModule): ) return - await self._upload(pubkey, advert.timestamp, advert.device_role, raw_hex, advert.lat, advert.lon) + await self._upload( + pubkey, advert.timestamp, advert.device_role, raw_hex, advert.lat, advert.lon + ) async def _upload( self, @@ -339,5 +341,3 @@ class MapUploadModule(FanoutModule): if self._last_error: return "error" return "connected" - - diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py index ae888be..99bf0eb 100644 --- a/tests/test_fanout_integration.py +++ b/tests/test_fanout_integration.py @@ -1835,6 +1835,7 @@ class TestMapUploadIntegration: await manager.broadcast_raw(advert_data) # Give the asyncio task a chance to run import asyncio + await asyncio.sleep(0.05) # _upload may or may not be called depending on parse result, but no exception @@ -1877,8 +1878,11 @@ class TestMapUploadIntegration: module, _ = manager._modules[cfg["id"]] with patch.object(module, "on_message", new_callable=AsyncMock) as mock_msg: - await manager.broadcast_message({"type": "CHAN", "conversation_key": "k1", "text": "hi"}) + await manager.broadcast_message( + {"type": "CHAN", "conversation_key": "k1", "text": "hi"} + ) import asyncio + await asyncio.sleep(0.05) mock_msg.assert_not_called() @@ -1893,10 +1897,12 @@ class TestMapUploadIntegration: config_type="map_upload", name="Map", config={"dry_run": True, "api_url": ""}, - scope={"messages": "all", "raw_packets": "none"}, # wrong, should be overridden by router + scope={ + "messages": "all", + "raw_packets": "none", + }, # wrong, should be overridden by router enabled=True, ) # The repository stores whatever the router passes — we test the router via HTTP # in test_api.py; here we just verify the module works with the correct scope. assert cfg["type"] == "map_upload" - diff --git a/tests/test_map_upload.py b/tests/test_map_upload.py index ee98835..f739298 100644 --- a/tests/test_map_upload.py +++ b/tests/test_map_upload.py @@ -381,7 +381,10 @@ class TestDryRun: 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": 915, "cr": 5, "sf": 10, "bw": 125}), + patch( + "app.fanout.map_upload._get_radio_params", + return_value={"freq": 915, "cr": 5, "sf": 10, "bw": 125}, + ), ): assert mod._client is not None post_mock = AsyncMock() @@ -406,7 +409,10 @@ class TestDryRun: 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}), + 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", 0.0, 0.0) assert mod._seen[pubkey] == 9999 @@ -453,7 +459,10 @@ class TestLiveSend: 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": 915, "cr": 5, "sf": 10, "bw": 125}), + patch( + "app.fanout.map_upload._get_radio_params", + return_value={"freq": 915, "cr": 5, "sf": 10, "bw": 125}, + ), ): assert mod._client is not None post_mock = AsyncMock(return_value=mock_response) @@ -483,7 +492,10 @@ class TestLiveSend: 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}), + patch( + "app.fanout.map_upload._get_radio_params", + return_value={"freq": 0, "cr": 0, "sf": 0, "bw": 0}, + ), ): assert mod._client is not None post_mock = AsyncMock(return_value=mock_response) @@ -512,7 +524,10 @@ class TestLiveSend: 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}), + patch( + "app.fanout.map_upload._get_radio_params", + return_value={"freq": 0, "cr": 0, "sf": 0, "bw": 0}, + ), ): assert mod._client is not None mod._client.post = AsyncMock(return_value=mock_response) # type: ignore[method-assign] @@ -539,7 +554,10 @@ class TestLiveSend: 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}), + patch( + "app.fanout.map_upload._get_radio_params", + return_value={"freq": 0, "cr": 0, "sf": 0, "bw": 0}, + ), ): assert mod._client is not None mod._client.post = AsyncMock(side_effect=exc) # type: ignore[method-assign] @@ -562,7 +580,10 @@ class TestLiveSend: 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}), + patch( + "app.fanout.map_upload._get_radio_params", + return_value={"freq": 0, "cr": 0, "sf": 0, "bw": 0}, + ), ): assert mod._client is not None mod._client.post = AsyncMock(side_effect=httpx.ConnectError("conn refused")) # type: ignore[method-assign] @@ -600,7 +621,10 @@ class TestPayloadStructure: 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": 915, "cr": 5, "sf": 10, "bw": 125}), + patch( + "app.fanout.map_upload._get_radio_params", + return_value={"freq": 915, "cr": 5, "sf": 10, "bw": 125}, + ), ): assert mod._client is not None mod._client.post = capture_post # type: ignore[method-assign] @@ -645,7 +669,10 @@ class TestPayloadStructure: 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}), + patch( + "app.fanout.map_upload._get_radio_params", + return_value={"freq": 0, "cr": 0, "sf": 0, "bw": 0}, + ), ): assert mod._client is not None mod._client.post = capture_post # type: ignore[method-assign] @@ -824,11 +851,13 @@ class TestGeofence: @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_radius_km": 100.0, - }) + mod = _make_module( + { + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": 100.0, + } + ) await mod.start() with ( @@ -846,11 +875,13 @@ class TestGeofence: @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_radius_km": 10.0, - }) + mod = _make_module( + { + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": 10.0, + } + ) await mod.start() with ( @@ -868,11 +899,13 @@ class TestGeofence: @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_radius_km": 100.0, - }) + mod = _make_module( + { + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": 100.0, + } + ) await mod.start() # Use a non-zero center so it's not treated as "not configured". @@ -897,11 +930,13 @@ class TestGeofence: @pytest.mark.asyncio async def test_geofence_skipped_when_lat_lon_zero(self): """geofence_enabled=True but radio (0, 0) → upload proceeds (geofence silently skipped).""" - mod = _make_module({ - "dry_run": True, - "geofence_enabled": True, - "geofence_radius_km": 10.0, - }) + mod = _make_module( + { + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": 10.0, + } + ) await mod.start() # Radio is at (0, 0) — treated as "not configured"; all nodes pass through. @@ -920,11 +955,13 @@ class TestGeofence: @pytest.mark.asyncio async def test_geofence_skipped_when_radio_unavailable(self): """geofence_enabled=True but radio is not connected → upload proceeds.""" - mod = _make_module({ - "dry_run": True, - "geofence_enabled": True, - "geofence_radius_km": 10.0, - }) + mod = _make_module( + { + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": 10.0, + } + ) await mod.start() mock_rt = MagicMock() @@ -944,11 +981,13 @@ class TestGeofence: @pytest.mark.asyncio async def test_dry_run_geofence_logs_distance(self): """dry_run + geofence_enabled must include the calculated distance in the log line.""" - mod = _make_module({ - "dry_run": True, - "geofence_enabled": True, - "geofence_radius_km": 100.0, - }) + mod = _make_module( + { + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": 100.0, + } + ) await mod.start() with ( @@ -989,11 +1028,13 @@ class TestGeofence: @pytest.mark.asyncio async def test_dry_run_geofence_no_distance_when_lat_lon_zero(self): """dry_run + geofence_enabled but radio (0, 0) → no distance note in log (skipped).""" - mod = _make_module({ - "dry_run": True, - "geofence_enabled": True, - "geofence_radius_km": 100.0, - }) + mod = _make_module( + { + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": 100.0, + } + ) await mod.start() with ( @@ -1012,4 +1053,3 @@ class TestGeofence: assert "km from observer" not in msg await mod.stop() - From 650a24a68c0750580591e90384f9d5b0174fadfb Mon Sep 17 00:00:00 2001 From: jkingsman Date: Thu, 26 Mar 2026 17:18:13 -0700 Subject: [PATCH 08/11] Centralize duplicated crypto code --- app/fanout/community_mqtt.py | 30 ++---------------------------- app/fanout/map_upload.py | 28 ++-------------------------- app/keystore.py | 28 +++++++++++++++++++++++++++- tests/test_community_mqtt.py | 10 +++++----- 4 files changed, 36 insertions(+), 60 deletions(-) diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py index b3af953..c9b2761 100644 --- a/app/fanout/community_mqtt.py +++ b/app/fanout/community_mqtt.py @@ -20,9 +20,8 @@ from datetime import datetime from typing import Any, Protocol import aiomqtt -import nacl.bindings - from app.fanout.mqtt_base import BaseMqttPublisher +from app.keystore import ed25519_sign_expanded from app.path_utils import parse_packet_envelope, split_path_hex from app.version_info import get_app_build_info @@ -40,9 +39,6 @@ _TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours _STATS_REFRESH_INTERVAL = 300 # 5 minutes _STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s -# Ed25519 group order -_L = 2**252 + 27742317777372353535851937790883648493 - # Route type mapping: bottom 2 bits of first byte _ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"} @@ -69,28 +65,6 @@ def _base64url_encode(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") -def _ed25519_sign_expanded( - message: bytes, scalar: bytes, prefix: bytes, public_key: bytes -) -> bytes: - """Sign a message using MeshCore's expanded Ed25519 key format. - - MeshCore stores 64-byte "orlp" format keys: scalar(32) || prefix(32). - Standard Ed25519 libraries expect seed format and would re-SHA-512 the key. - This performs the signing manually using the already-expanded key material. - - Port of meshcore-packet-capture's ed25519_sign_with_expanded_key(). - """ - # r = SHA-512(prefix || message) mod L - r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L - # R = r * B (base point multiplication) - R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little")) - # k = SHA-512(R || public_key || message) mod L - k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L - # s = (r + k * scalar) mod L - s = (r + k * int.from_bytes(scalar, "little")) % _L - return R + s.to_bytes(32, "little") - - def _generate_jwt_token( private_key: bytes, public_key: bytes, @@ -127,7 +101,7 @@ def _generate_jwt_token( scalar = private_key[:32] prefix = private_key[32:] - signature = _ed25519_sign_expanded(signing_input, scalar, prefix, public_key) + signature = ed25519_sign_expanded(signing_input, scalar, prefix, public_key) return f"{header_b64}.{payload_b64}.{signature.hex()}" diff --git a/app/fanout/map_upload.py b/app/fanout/map_upload.py index 5c23887..2f258f2 100644 --- a/app/fanout/map_upload.py +++ b/app/fanout/map_upload.py @@ -41,7 +41,7 @@ import httpx from app.decoder import parse_advertisement, parse_packet from app.fanout.base import FanoutModule -from app.keystore import get_private_key, get_public_key +from app.keystore import ed25519_sign_expanded, get_private_key, get_public_key from app.services.radio_runtime import radio_runtime logger = logging.getLogger(__name__) @@ -56,30 +56,6 @@ _REUPLOAD_SECONDS = 3600 # blocklist so that new roles cannot accidentally start populating the map. _ALLOWED_DEVICE_ROLES = {2, 3} -# Ed25519 group order (L) -_L = 2**252 + 27742317777372353535851937790883648493 - - -def _ed25519_sign_expanded( - message: bytes, scalar: bytes, prefix: bytes, public_key: bytes -) -> bytes: - """Sign using MeshCore's expanded Ed25519 key format. - - MeshCore stores 64-byte keys as scalar(32) || prefix(32). Standard - Ed25519 libraries expect seed format and would re-SHA-512 the key, so - we perform the signing manually using the already-expanded key material. - - Mirrors the implementation in app/fanout/community_mqtt.py. - """ - import nacl.bindings - - r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L - R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little")) - k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L - s = (r + k * int.from_bytes(scalar, "little")) % _L - return R + s.to_bytes(32, "little") - - def _get_radio_params() -> dict: """Read radio frequency parameters from the connected radio's self_info. @@ -278,7 +254,7 @@ class MapUploadModule(FanoutModule): data_hash = hashlib.sha256(json_str.encode()).digest() scalar = private_key[:32] prefix_bytes = private_key[32:] - signature = _ed25519_sign_expanded(data_hash, scalar, prefix_bytes, public_key) + signature = ed25519_sign_expanded(data_hash, scalar, prefix_bytes, public_key) request_payload = { "data": json_str, diff --git a/app/keystore.py b/app/keystore.py index 4a1a86c..27a157c 100644 --- a/app/keystore.py +++ b/app/keystore.py @@ -1,14 +1,19 @@ """ -Ephemeral keystore for storing sensitive keys in memory. +Ephemeral keystore for storing sensitive keys in memory, plus the Ed25519 +signing primitive used by fanout modules that need to sign requests with the +radio's own key. The private key is stored in memory only and is never persisted to disk. It's exported from the radio on startup and reconnect, then used for server-side decryption of direct messages. """ +import hashlib import logging from typing import TYPE_CHECKING +import nacl.bindings + from meshcore import EventType from app.decoder import derive_public_key @@ -25,11 +30,32 @@ NO_EVENT_RECEIVED_GUIDANCE = ( "issue commands to the radio." ) +# Ed25519 group order (L) — used in the expanded signing primitive below +_L = 2**252 + 27742317777372353535851937790883648493 + # In-memory storage for the private key and derived public key _private_key: bytes | None = None _public_key: bytes | None = None +def ed25519_sign_expanded( + message: bytes, scalar: bytes, prefix: bytes, public_key: bytes +) -> bytes: + """Sign a message using MeshCore's expanded Ed25519 key format. + + MeshCore stores 64-byte keys as scalar(32) || prefix(32). Standard + Ed25519 libraries expect seed format and would re-SHA-512 the key, so we + perform the signing manually using the already-expanded key material. + + Port of meshcore-packet-capture's ed25519_sign_with_expanded_key(). + """ + r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L + R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little")) + k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L + s = (r + k * int.from_bytes(scalar, "little")) % _L + return R + s.to_bytes(32, "little") + + def clear_keys() -> None: """Clear any stored private/public key material from memory.""" global _private_key, _public_key diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py index 5c65c94..5cd5abb 100644 --- a/tests/test_community_mqtt.py +++ b/tests/test_community_mqtt.py @@ -19,11 +19,11 @@ from app.fanout.community_mqtt import ( _build_status_topic, _calculate_packet_hash, _decode_packet_fields, - _ed25519_sign_expanded, _format_raw_packet, _generate_jwt_token, _get_client_version, ) +from app.keystore import ed25519_sign_expanded from app.fanout.mqtt_community import ( _config_to_settings, _publish_community_packet, @@ -173,13 +173,13 @@ class TestEddsaSignExpanded: def test_produces_64_byte_signature(self): private_key, public_key = _make_test_keys() message = b"test message" - sig = _ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key) + sig = ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key) assert len(sig) == 64 def test_signature_verifies_with_nacl(self): private_key, public_key = _make_test_keys() message = b"hello world" - sig = _ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key) + sig = ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key) signed_message = sig + message verified = nacl.bindings.crypto_sign_open(signed_message, public_key) @@ -187,8 +187,8 @@ class TestEddsaSignExpanded: def test_different_messages_produce_different_signatures(self): private_key, public_key = _make_test_keys() - sig1 = _ed25519_sign_expanded(b"msg1", private_key[:32], private_key[32:], public_key) - sig2 = _ed25519_sign_expanded(b"msg2", private_key[:32], private_key[32:], public_key) + sig1 = ed25519_sign_expanded(b"msg1", private_key[:32], private_key[32:], public_key) + sig2 = ed25519_sign_expanded(b"msg2", private_key[:32], private_key[32:], public_key) assert sig1 != sig2 From ac5e71d6f210b1eb7c710948620266525f28b59f Mon Sep 17 00:00:00 2001 From: jkingsman Date: Thu, 26 Mar 2026 17:20:13 -0700 Subject: [PATCH 09/11] Validate geofence radius to be positive --- app/routers/fanout.py | 8 ++++ tests/test_fanout.py | 85 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/app/routers/fanout.py b/app/routers/fanout.py index da0c9c8..f2ec2de 100644 --- a/app/routers/fanout.py +++ b/app/routers/fanout.py @@ -308,6 +308,14 @@ def _validate_map_upload_config(config: dict) -> None: # Persist the cleaned value (empty string means use the module default) config["api_url"] = api_url config["dry_run"] = bool(config.get("dry_run", True)) + config["geofence_enabled"] = bool(config.get("geofence_enabled", False)) + try: + radius = float(config.get("geofence_radius_km", 0) or 0) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="geofence_radius_km must be a number") from None + if radius < 0: + raise HTTPException(status_code=400, detail="geofence_radius_km must be >= 0") + config["geofence_radius_km"] = radius def _enforce_scope(config_type: str, scope: dict) -> dict: diff --git a/tests/test_fanout.py b/tests/test_fanout.py index ad656d7..1ca1031 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -707,6 +707,91 @@ class TestSqsValidation: {"queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"} ) +class TestMapUploadValidation: + def test_rejects_bad_api_url_scheme(self): + from fastapi import HTTPException + + from app.routers.fanout import _validate_map_upload_config + + with pytest.raises(HTTPException) as exc_info: + _validate_map_upload_config({"api_url": "ftp://example.com"}) + assert exc_info.value.status_code == 400 + assert "api_url" in exc_info.value.detail + + def test_accepts_empty_api_url(self): + from app.routers.fanout import _validate_map_upload_config + + config = {"api_url": ""} + _validate_map_upload_config(config) + assert config["api_url"] == "" + + def test_accepts_valid_api_url(self): + from app.routers.fanout import _validate_map_upload_config + + config = {"api_url": "https://custom.example.com/upload"} + _validate_map_upload_config(config) + assert config["api_url"] == "https://custom.example.com/upload" + + def test_normalizes_dry_run_to_bool(self): + from app.routers.fanout import _validate_map_upload_config + + config = {"dry_run": 1} + _validate_map_upload_config(config) + assert config["dry_run"] is True + + def test_normalizes_geofence_enabled_to_bool(self): + from app.routers.fanout import _validate_map_upload_config + + config = {"geofence_enabled": 1} + _validate_map_upload_config(config) + assert config["geofence_enabled"] is True + + def test_normalizes_geofence_radius_to_float(self): + from app.routers.fanout import _validate_map_upload_config + + config = {"geofence_radius_km": 100} + _validate_map_upload_config(config) + assert config["geofence_radius_km"] == 100.0 + assert isinstance(config["geofence_radius_km"], float) + + def test_rejects_negative_geofence_radius(self): + from fastapi import HTTPException + + from app.routers.fanout import _validate_map_upload_config + + with pytest.raises(HTTPException) as exc_info: + _validate_map_upload_config({"geofence_radius_km": -1}) + assert exc_info.value.status_code == 400 + assert "geofence_radius_km" in exc_info.value.detail + + def test_rejects_non_numeric_geofence_radius(self): + from fastapi import HTTPException + + from app.routers.fanout import _validate_map_upload_config + + with pytest.raises(HTTPException) as exc_info: + _validate_map_upload_config({"geofence_radius_km": "bad"}) + assert exc_info.value.status_code == 400 + assert "geofence_radius_km" in exc_info.value.detail + + def test_accepts_zero_geofence_radius(self): + from app.routers.fanout import _validate_map_upload_config + + config = {"geofence_radius_km": 0} + _validate_map_upload_config(config) + assert config["geofence_radius_km"] == 0.0 + + def test_defaults_applied_when_keys_absent(self): + from app.routers.fanout import _validate_map_upload_config + + config = {} + _validate_map_upload_config(config) + assert config["api_url"] == "" + assert config["dry_run"] is True + assert config["geofence_enabled"] is False + assert config["geofence_radius_km"] == 0.0 + + def test_enforce_scope_sqs_preserves_raw_packets_setting(self): from app.routers.fanout import _enforce_scope From 1405df6039d4b0c50717921b41bdfb4625291936 Mon Sep 17 00:00:00 2001 From: jkingsman Date: Thu, 26 Mar 2026 17:22:42 -0700 Subject: [PATCH 10/11] Beef up some noopy tests --- tests/test_fanout.py | 7 +++++ tests/test_fanout_integration.py | 18 ----------- tests/test_map_upload.py | 54 +++++++++++++++++++++++++------- 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 1ca1031..9c5461c 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -791,6 +791,13 @@ class TestMapUploadValidation: assert config["geofence_enabled"] is False assert config["geofence_radius_km"] == 0.0 + def test_enforce_scope_map_upload_forces_raw_only(self): + """map_upload scope is always fixed regardless of what the caller passes.""" + from app.routers.fanout import _enforce_scope + + scope = _enforce_scope("map_upload", {"messages": "all", "raw_packets": "none"}) + assert scope == {"messages": "none", "raw_packets": "all"} + def test_enforce_scope_sqs_preserves_raw_packets_setting(self): from app.routers.fanout import _enforce_scope diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py index 99bf0eb..e520cc8 100644 --- a/tests/test_fanout_integration.py +++ b/tests/test_fanout_integration.py @@ -1888,21 +1888,3 @@ class TestMapUploadIntegration: await manager.stop_all() - @pytest.mark.asyncio - async def test_map_upload_scope_enforced_on_create(self, integration_db): - """Scope for map_upload is always fixed to raw_packets: all, messages: none.""" - # Even if a custom scope is passed, the router enforces the correct one. - # Here we verify the DB record has the enforced scope. - cfg = await FanoutConfigRepository.create( - config_type="map_upload", - name="Map", - config={"dry_run": True, "api_url": ""}, - scope={ - "messages": "all", - "raw_packets": "none", - }, # wrong, should be overridden by router - enabled=True, - ) - # The repository stores whatever the router passes — we test the router via HTTP - # in test_api.py; here we just verify the module works with the correct scope. - assert cfg["type"] == "map_upload" diff --git a/tests/test_map_upload.py b/tests/test_map_upload.py index f739298..32b9361 100644 --- a/tests/test_map_upload.py +++ b/tests/test_map_upload.py @@ -898,35 +898,67 @@ class TestGeofence: @pytest.mark.asyncio async def test_node_at_exact_boundary_passes(self): - """Node at exactly the fence radius must be allowed (<=, not <).""" + """Node at exactly the fence radius must be allowed (> not >=). + + We compute the haversine distance to the node, then set the radius to + exactly that value. The geofence check is ``dist > radius``, so a node + sitting precisely on the boundary must pass through. + """ + fence_lat, fence_lon = 1.0, 0.0 + node_lat, node_lon = 1.9, 0.0 + exact_dist = _haversine_km(fence_lat, fence_lon, node_lat, node_lon) + mod = _make_module( { "dry_run": True, "geofence_enabled": True, - "geofence_radius_km": 100.0, + "geofence_radius_km": exact_dist, # radius == distance → must pass } ) await mod.start() - # Use a non-zero center so it's not treated as "not configured". - # Purely latitudinal haversine distance is origin-independent, so - # 0.8993° from (1.0, 0.0) gives the same ~100 km as from (0.0, 0.0). - fence_lat, fence_lon = 1.0, 0.0 - node_lat = fence_lat + 0.8993 - dist = _haversine_km(fence_lat, fence_lon, node_lat, fence_lon) - assert dist <= 100.0, f"Expected <=100 km, got {dist:.3f}" - with ( _mock_radio_runtime_with_location(fence_lat, fence_lon), 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=_FAKE_RADIO_PARAMS), ): - await mod._upload("ab" * 32, 1000, 2, "aabb", node_lat, fence_lon) + await mod._upload("ab" * 32, 1000, 2, "aabb", node_lat, node_lon) assert ("ab" * 32) in mod._seen await mod.stop() + @pytest.mark.asyncio + async def test_node_just_outside_boundary_skipped(self): + """Node one metre beyond the fence radius must be filtered. + + Companion to test_node_at_exact_boundary_passes: shrink the radius by a + tiny epsilon so the same node is now strictly outside the fence. + """ + fence_lat, fence_lon = 1.0, 0.0 + node_lat, node_lon = 1.9, 0.0 + exact_dist = _haversine_km(fence_lat, fence_lon, node_lat, node_lon) + + mod = _make_module( + { + "dry_run": True, + "geofence_enabled": True, + "geofence_radius_km": exact_dist - 0.001, # 1 metre short → must be filtered + } + ) + await mod.start() + + with ( + _mock_radio_runtime_with_location(fence_lat, fence_lon), + 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=_FAKE_RADIO_PARAMS), + ): + await mod._upload("ab" * 32, 1000, 2, "aabb", node_lat, node_lon) + assert ("ab" * 32) not in mod._seen + + await mod.stop() + @pytest.mark.asyncio async def test_geofence_skipped_when_lat_lon_zero(self): """geofence_enabled=True but radio (0, 0) → upload proceeds (geofence silently skipped).""" From 79d5e69ee0322a55ba855034f9edf93fb679554c Mon Sep 17 00:00:00 2001 From: jkingsman Date: Thu, 26 Mar 2026 17:59:59 -0700 Subject: [PATCH 11/11] Format + lint --- app/fanout/community_mqtt.py | 1 + app/fanout/map_upload.py | 1 + app/keystore.py | 5 +---- tests/test_community_mqtt.py | 2 +- tests/test_fanout.py | 2 +- tests/test_fanout_integration.py | 1 - 6 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py index c9b2761..632572d 100644 --- a/app/fanout/community_mqtt.py +++ b/app/fanout/community_mqtt.py @@ -20,6 +20,7 @@ from datetime import datetime from typing import Any, Protocol import aiomqtt + from app.fanout.mqtt_base import BaseMqttPublisher from app.keystore import ed25519_sign_expanded from app.path_utils import parse_packet_envelope, split_path_hex diff --git a/app/fanout/map_upload.py b/app/fanout/map_upload.py index 2f258f2..a9af2a7 100644 --- a/app/fanout/map_upload.py +++ b/app/fanout/map_upload.py @@ -56,6 +56,7 @@ _REUPLOAD_SECONDS = 3600 # blocklist so that new roles cannot accidentally start populating the map. _ALLOWED_DEVICE_ROLES = {2, 3} + def _get_radio_params() -> dict: """Read radio frequency parameters from the connected radio's self_info. diff --git a/app/keystore.py b/app/keystore.py index 27a157c..28031e9 100644 --- a/app/keystore.py +++ b/app/keystore.py @@ -13,7 +13,6 @@ import logging from typing import TYPE_CHECKING import nacl.bindings - from meshcore import EventType from app.decoder import derive_public_key @@ -38,9 +37,7 @@ _private_key: bytes | None = None _public_key: bytes | None = None -def ed25519_sign_expanded( - message: bytes, scalar: bytes, prefix: bytes, public_key: bytes -) -> bytes: +def ed25519_sign_expanded(message: bytes, scalar: bytes, prefix: bytes, public_key: bytes) -> bytes: """Sign a message using MeshCore's expanded Ed25519 key format. MeshCore stores 64-byte keys as scalar(32) || prefix(32). Standard diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py index 5cd5abb..462860d 100644 --- a/tests/test_community_mqtt.py +++ b/tests/test_community_mqtt.py @@ -23,12 +23,12 @@ from app.fanout.community_mqtt import ( _generate_jwt_token, _get_client_version, ) -from app.keystore import ed25519_sign_expanded from app.fanout.mqtt_community import ( _config_to_settings, _publish_community_packet, _render_packet_topic, ) +from app.keystore import ed25519_sign_expanded def _make_test_keys() -> tuple[bytes, bytes]: diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 9c5461c..8422194 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -707,6 +707,7 @@ class TestSqsValidation: {"queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"} ) + class TestMapUploadValidation: def test_rejects_bad_api_url_scheme(self): from fastapi import HTTPException @@ -798,7 +799,6 @@ class TestMapUploadValidation: scope = _enforce_scope("map_upload", {"messages": "all", "raw_packets": "none"}) assert scope == {"messages": "none", "raw_packets": "all"} - def test_enforce_scope_sqs_preserves_raw_packets_setting(self): from app.routers.fanout import _enforce_scope diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py index e520cc8..a91048e 100644 --- a/tests/test_fanout_integration.py +++ b/tests/test_fanout_integration.py @@ -1887,4 +1887,3 @@ class TestMapUploadIntegration: mock_msg.assert_not_called() await manager.stop_all() -