mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-02 06:53:09 +02:00
Switching to using radio lat/lon, rename Community MQTT to Community Sharing, update AGENTS_fanout.md
This commit is contained in:
@@ -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
|
||||
|
||||
+34
-17
@@ -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()
|
||||
|
||||
@@ -16,7 +16,7 @@ const BotCodeEditor = lazy(() =>
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
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<string, unknown>) => void;
|
||||
}) {
|
||||
const isDryRun = config.dry_run !== false;
|
||||
const [radioLat, setRadioLat] = useState<number | null>(null);
|
||||
const [radioLon, setRadioLon] = useState<number | null>(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 (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -1156,48 +1175,31 @@ function MapUploadConfigEditor({
|
||||
<div>
|
||||
<span className="text-sm font-medium">Enable Geofence</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only upload nodes whose location falls within the configured radius of your position.
|
||||
Helps exclude nodes with false or spoofed coordinates.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!!config.geofence_enabled && (
|
||||
<div className="space-y-3 pl-7">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-map-geofence-lat">My Latitude</Label>
|
||||
<Input
|
||||
id="fanout-map-geofence-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="e.g. 51.5"
|
||||
value={(config.geofence_lat as number | undefined) ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
geofence_lat: e.target.value === '' ? 0 : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{!radioLatLonConfigured && (
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
Your radio does not currently have a latitude/longitude configured. Geofencing will be
|
||||
silently skipped until coordinates are set in{' '}
|
||||
<strong>Settings → Radio → Location</strong>.
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-map-geofence-lon">My Longitude</Label>
|
||||
<Input
|
||||
id="fanout-map-geofence-lon"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="e.g. -0.1"
|
||||
value={(config.geofence_lon as number | undefined) ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
geofence_lon: e.target.value === '' ? 0 : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{radioLatLonConfigured && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Using radio position{' '}
|
||||
<code>
|
||||
{radioLat?.toFixed(5)}, {radioLon?.toFixed(5)}
|
||||
</code>{' '}
|
||||
as the geofence center. Update coordinates in Radio Settings to move the center.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-map-geofence-radius">Radius (km)</Label>
|
||||
<Input
|
||||
@@ -1215,7 +1217,7 @@ function MapUploadConfigEditor({
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
+116
-49
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user