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() +