Switching to using radio lat/lon, rename Community MQTT to Community Sharing, update AGENTS_fanout.md

This commit is contained in:
Kizniche
2026-03-26 19:55:30 -04:00
parent b7972f50a8
commit efeb047116
5 changed files with 225 additions and 110 deletions
+14
View File
@@ -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
View File
@@ -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&apos;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 &rarr; Radio &rarr; 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&apos;s position will not be uploaded.
</p>
</div>
</div>
+17 -2
View File
@@ -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
View File
@@ -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()