Add zero-hop impulse advert. Closes #83.

This commit is contained in:
Jack Kingsman
2026-03-18 19:59:08 -07:00
parent d8e22ef4af
commit b832239e22
13 changed files with 159 additions and 45 deletions
+1 -1
View File
@@ -297,7 +297,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location | | GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported | | PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
| PUT | `/api/radio/private-key` | Import private key to radio | | PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement | | POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors | | POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected | | POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts | | POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
+1 -1
View File
@@ -168,7 +168,7 @@ app/
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off - `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it - `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
- `PUT /radio/private-key` - `PUT /radio/private-key`
- `POST /radio/advertise` - `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors - `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
- `POST /radio/disconnect` - `POST /radio/disconnect`
- `POST /radio/reboot` - `POST /radio/reboot`
+23 -10
View File
@@ -14,6 +14,7 @@ import logging
import math import math
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Literal
from meshcore import EventType, MeshCore from meshcore import EventType, MeshCore
@@ -37,6 +38,8 @@ logger = logging.getLogger(__name__)
DEFAULT_MAX_CHANNELS = 40 DEFAULT_MAX_CHANNELS = 40
AdvertMode = Literal["flood", "zero_hop"]
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]: def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
"""Return key contact fields for sync failure diagnostics.""" """Return key contact fields for sync failure diagnostics."""
@@ -686,7 +689,12 @@ async def stop_message_polling():
logger.info("Stopped periodic message polling") logger.info("Stopped periodic message polling")
async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool: async def send_advertisement(
mc: MeshCore,
*,
force: bool = False,
mode: AdvertMode = "flood",
) -> bool:
"""Send an advertisement to announce presence on the mesh. """Send an advertisement to announce presence on the mesh.
Respects the configured advert_interval - won't send if not enough time Respects the configured advert_interval - won't send if not enough time
@@ -695,11 +703,15 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
Args: Args:
mc: The MeshCore instance to use for the advertisement. mc: The MeshCore instance to use for the advertisement.
force: If True, send immediately regardless of interval. force: If True, send immediately regardless of interval.
mode: Advertisement mode. Flood adverts use the persisted flood-advert
throttle state; zero-hop adverts currently send immediately.
Returns True if successful, False otherwise (including if throttled). Returns True if successful, False otherwise (including if throttled).
""" """
# Check if enough time has elapsed (unless forced) use_flood = mode == "flood"
if not force:
# Only flood adverts currently participate in persisted throttle state.
if use_flood and not force:
settings = await AppSettingsRepository.get() settings = await AppSettingsRepository.get()
interval = settings.advert_interval interval = settings.advert_interval
last_time = settings.last_advert_time last_time = settings.last_advert_time
@@ -726,18 +738,19 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
return False return False
try: try:
result = await mc.commands.send_advert(flood=True) result = await mc.commands.send_advert(flood=use_flood)
if result.type == EventType.OK: if result.type == EventType.OK:
# Update last_advert_time in database if use_flood:
now = int(time.time()) # Track flood advert timing for periodic/startup throttling.
await AppSettingsRepository.update(last_advert_time=now) now = int(time.time())
logger.info("Advertisement sent successfully") await AppSettingsRepository.update(last_advert_time=now)
logger.info("%s advertisement sent successfully", mode.replace("_", "-"))
return True return True
else: else:
logger.warning("Failed to send advertisement: %s", result.payload) logger.warning("Failed to send %s advertisement: %s", mode, result.payload)
return False return False
except Exception as e: except Exception as e:
logger.warning("Error sending advertisement: %s", e, exc_info=True) logger.warning("Error sending %s advertisement: %s", mode, e, exc_info=True)
return False return False
+17 -8
View File
@@ -32,6 +32,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"]) router = APIRouter(prefix="/radio", tags=["radio"])
AdvertLocationSource = Literal["off", "current"] AdvertLocationSource = Literal["off", "current"]
RadioAdvertMode = Literal["flood", "zero_hop"]
DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"] DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"]
DISCOVERY_WINDOW_SECONDS = 8.0 DISCOVERY_WINDOW_SECONDS = 8.0
_DISCOVERY_TARGET_BITS = { _DISCOVERY_TARGET_BITS = {
@@ -104,6 +105,13 @@ class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string") private_key: str = Field(description="Private key as hex string")
class RadioAdvertiseRequest(BaseModel):
mode: RadioAdvertMode = Field(
default="flood",
description="Advertisement mode: flood through repeaters or zero-hop local only",
)
def _monotonic() -> float: def _monotonic() -> float:
return time.monotonic() return time.monotonic()
@@ -266,24 +274,25 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
@router.post("/advertise") @router.post("/advertise")
async def send_advertisement() -> dict: async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> dict:
"""Send a flood advertisement to announce presence on the mesh. """Send an advertisement to announce presence on the mesh.
Manual advertisement requests always send immediately, updating the Manual advertisement requests always send immediately. Flood adverts update
last_advert_time which affects when the next periodic/startup advert the shared flood-advert timing state used by periodic/startup advertising;
can occur. zero-hop adverts currently do not.
Returns: Returns:
status: "ok" if sent successfully status: "ok" if sent successfully
""" """
require_connected() require_connected()
mode: RadioAdvertMode = request.mode if request is not None else "flood"
logger.info("Sending flood advertisement") logger.info("Sending %s advertisement", mode.replace("_", "-"))
async with radio_manager.radio_operation("manual_advertisement") as mc: async with radio_manager.radio_operation("manual_advertisement") as mc:
success = await do_send_advertisement(mc, force=True) success = await do_send_advertisement(mc, force=True, mode=mode)
if not success: if not success:
raise HTTPException(status_code=500, detail="Failed to send advertisement") raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement")
return {"status": "ok"} return {"status": "ok"}
+1
View File
@@ -250,6 +250,7 @@ High-level state is delegated to hooks:
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true. - `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path. - Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
- The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same `onAdvertise(mode)` seam.
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors. - Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers. - Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
+3 -1
View File
@@ -15,6 +15,7 @@ import type {
MessagesAroundResponse, MessagesAroundResponse,
MigratePreferencesRequest, MigratePreferencesRequest,
MigratePreferencesResponse, MigratePreferencesResponse,
RadioAdvertMode,
RadioConfig, RadioConfig,
RadioConfigUpdate, RadioConfigUpdate,
RadioDiscoveryResponse, RadioDiscoveryResponse,
@@ -95,9 +96,10 @@ export const api = {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ private_key: privateKey }), body: JSON.stringify({ private_key: privateKey }),
}), }),
sendAdvertisement: () => sendAdvertisement: (mode: RadioAdvertMode = 'flood') =>
fetchJson<{ status: string }>('/radio/advertise', { fetchJson<{ status: string }>('/radio/advertise', {
method: 'POST', method: 'POST',
body: JSON.stringify({ mode }),
}), }),
discoverMesh: (target: RadioDiscoveryTarget) => discoverMesh: (target: RadioDiscoveryTarget) =>
fetchJson<RadioDiscoveryResponse>('/radio/discover', { fetchJson<RadioDiscoveryResponse>('/radio/discover', {
+2 -1
View File
@@ -3,6 +3,7 @@ import type {
AppSettings, AppSettings,
AppSettingsUpdate, AppSettingsUpdate,
HealthStatus, HealthStatus,
RadioAdvertMode,
RadioConfig, RadioConfig,
RadioConfigUpdate, RadioConfigUpdate,
RadioDiscoveryResponse, RadioDiscoveryResponse,
@@ -35,7 +36,7 @@ interface SettingsModalBaseProps {
onReboot: () => Promise<void>; onReboot: () => Promise<void>;
onDisconnect: () => Promise<void>; onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>; onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>; onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery: RadioDiscoveryResponse | null; meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null; meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>; onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -11,6 +11,7 @@ import type {
AppSettings, AppSettings,
AppSettingsUpdate, AppSettingsUpdate,
HealthStatus, HealthStatus,
RadioAdvertMode,
RadioConfig, RadioConfig,
RadioConfigUpdate, RadioConfigUpdate,
RadioDiscoveryResponse, RadioDiscoveryResponse,
@@ -45,7 +46,7 @@ export function SettingsRadioSection({
onReboot: () => Promise<void>; onReboot: () => Promise<void>;
onDisconnect: () => Promise<void>; onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>; onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>; onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery: RadioDiscoveryResponse | null; meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null; meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>; onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -82,7 +83,7 @@ export function SettingsRadioSection({
const [floodError, setFloodError] = useState<string | null>(null); const [floodError, setFloodError] = useState<string | null>(null);
// Advertise state // Advertise state
const [advertising, setAdvertising] = useState(false); const [advertisingMode, setAdvertisingMode] = useState<RadioAdvertMode | null>(null);
const [discoverError, setDiscoverError] = useState<string | null>(null); const [discoverError, setDiscoverError] = useState<string | null>(null);
const [connectionBusy, setConnectionBusy] = useState(false); const [connectionBusy, setConnectionBusy] = useState(false);
@@ -295,12 +296,12 @@ export function SettingsRadioSection({
} }
}; };
const handleAdvertise = async () => { const handleAdvertise = async (mode: RadioAdvertMode) => {
setAdvertising(true); setAdvertisingMode(mode);
try { try {
await onAdvertise(); await onAdvertise(mode);
} finally { } finally {
setAdvertising(false); setAdvertisingMode(null);
} }
}; };
@@ -742,15 +743,25 @@ export function SettingsRadioSection({
<div className="space-y-2"> <div className="space-y-2">
<Label>Send Advertisement</Label> <Label>Send Advertisement</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Send a flood advertisement to announce your presence on the mesh network. Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less
airtime.
</p> </p>
<Button <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
onClick={handleAdvertise} <Button
disabled={advertising || !health?.radio_connected} onClick={() => handleAdvertise('flood')}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground" disabled={advertisingMode !== null || !health?.radio_connected}
> className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
{advertising ? 'Sending...' : 'Send Advertisement'} >
</Button> {advertisingMode === 'flood' ? 'Sending...' : 'Send Flood Advertisement'}
</Button>
<Button
onClick={() => handleAdvertise('zero_hop')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full"
>
{advertisingMode === 'zero_hop' ? 'Sending...' : 'Send Zero-Hop Advertisement'}
</Button>
</div>
{!health?.radio_connected && ( {!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p> <p className="text-sm text-destructive">Radio not connected</p>
)} )}
+7 -5
View File
@@ -4,6 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner'; import { toast } from '../components/ui/sonner';
import type { import type {
HealthStatus, HealthStatus,
RadioAdvertMode,
RadioConfig, RadioConfig,
RadioConfigUpdate, RadioConfigUpdate,
RadioDiscoveryResponse, RadioDiscoveryResponse,
@@ -93,13 +94,14 @@ export function useRadioControl() {
} }
}, [fetchConfig]); }, [fetchConfig]);
const handleAdvertise = useCallback(async () => { const handleAdvertise = useCallback(async (mode: RadioAdvertMode = 'flood') => {
try { try {
await api.sendAdvertisement(); await api.sendAdvertisement(mode);
toast.success('Advertisement sent'); toast.success(mode === 'zero_hop' ? 'Zero-hop advertisement sent' : 'Advertisement sent');
} catch (err) { } catch (err) {
console.error('Failed to send advertisement:', err); const label = mode === 'zero_hop' ? 'zero-hop advertisement' : 'advertisement';
toast.error('Failed to send advertisement', { console.error(`Failed to send ${label}:`, err);
toast.error(`Failed to send ${label}`, {
description: err instanceof Error ? err.message : 'Check radio connection', description: err instanceof Error ? err.message : 'Check radio connection',
}); });
} }
+17 -2
View File
@@ -305,7 +305,7 @@ describe('fetchJson (via api methods)', () => {
expect(options.method).toBe('DELETE'); expect(options.method).toBe('DELETE');
}); });
it('sends POST without body for sendAdvertisement', async () => { it('sends POST with flood mode for sendAdvertisement', async () => {
installMockFetch(); installMockFetch();
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
@@ -317,7 +317,22 @@ describe('fetchJson (via api methods)', () => {
const [url, options] = mockFetch.mock.calls[0]; const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise'); expect(url).toBe('/api/radio/advertise');
expect(options.method).toBe('POST'); expect(options.method).toBe('POST');
expect(options.body).toBeUndefined(); expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
});
it('sends POST with zero-hop mode for sendAdvertisement', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'ok' }),
});
await api.sendAdvertisement('zero_hop');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise');
expect(options.method).toBe('POST');
expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
}); });
}); });
+21 -1
View File
@@ -6,6 +6,7 @@ import type {
AppSettings, AppSettings,
AppSettingsUpdate, AppSettingsUpdate,
HealthStatus, HealthStatus,
RadioAdvertMode,
RadioConfig, RadioConfig,
RadioConfigUpdate, RadioConfigUpdate,
RadioDiscoveryResponse, RadioDiscoveryResponse,
@@ -74,6 +75,7 @@ function renderModal(overrides?: {
onReboot?: () => Promise<void>; onReboot?: () => Promise<void>;
onDisconnect?: () => Promise<void>; onDisconnect?: () => Promise<void>;
onReconnect?: () => Promise<void>; onReconnect?: () => Promise<void>;
onAdvertise?: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery?: RadioDiscoveryResponse | null; meshDiscovery?: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null; meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>; onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -93,6 +95,7 @@ function renderModal(overrides?: {
const onReboot = overrides?.onReboot ?? vi.fn(async () => {}); const onReboot = overrides?.onReboot ?? vi.fn(async () => {});
const onDisconnect = overrides?.onDisconnect ?? vi.fn(async () => {}); const onDisconnect = overrides?.onDisconnect ?? vi.fn(async () => {});
const onReconnect = overrides?.onReconnect ?? vi.fn(async () => {}); const onReconnect = overrides?.onReconnect ?? vi.fn(async () => {});
const onAdvertise = overrides?.onAdvertise ?? vi.fn(async (_mode: RadioAdvertMode) => {});
const onDiscoverMesh = overrides?.onDiscoverMesh ?? vi.fn(async () => {}); const onDiscoverMesh = overrides?.onDiscoverMesh ?? vi.fn(async () => {});
const commonProps = { const commonProps = {
@@ -108,7 +111,7 @@ function renderModal(overrides?: {
onReboot, onReboot,
onDisconnect, onDisconnect,
onReconnect, onReconnect,
onAdvertise: vi.fn(async () => {}), onAdvertise,
meshDiscovery: overrides?.meshDiscovery ?? null, meshDiscovery: overrides?.meshDiscovery ?? null,
meshDiscoveryLoadingTarget: overrides?.meshDiscoveryLoadingTarget ?? null, meshDiscoveryLoadingTarget: overrides?.meshDiscoveryLoadingTarget ?? null,
onDiscoverMesh, onDiscoverMesh,
@@ -135,6 +138,7 @@ function renderModal(overrides?: {
onReboot, onReboot,
onDisconnect, onDisconnect,
onReconnect, onReconnect,
onAdvertise,
onDiscoverMesh, onDiscoverMesh,
view, view,
}; };
@@ -206,6 +210,22 @@ describe('SettingsModal', () => {
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument(); expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
}); });
it('renders flood and zero-hop advert buttons and passes the selected mode', async () => {
const onAdvertise = vi.fn(async (_mode: RadioAdvertMode) => {});
renderModal({ onAdvertise });
openRadioSection();
fireEvent.click(screen.getByRole('button', { name: 'Send Flood Advertisement' }));
await waitFor(() => {
expect(onAdvertise).toHaveBeenCalledWith('flood');
});
fireEvent.click(screen.getByRole('button', { name: 'Send Zero-Hop Advertisement' }));
await waitFor(() => {
expect(onAdvertise).toHaveBeenCalledWith('zero_hop');
});
});
it('shows radio-unavailable message when config is null', () => { it('shows radio-unavailable message when config is null', () => {
renderModal({ config: null }); renderModal({ config: null });
+2
View File
@@ -45,6 +45,8 @@ export interface RadioDiscoveryResponse {
results: RadioDiscoveryResult[]; results: RadioDiscoveryResult[];
} }
export type RadioAdvertMode = 'flood' | 'zero_hop';
export interface FanoutStatusEntry { export interface FanoutStatusEntry {
name: string; name: string;
type: string; type: string;
+39 -1
View File
@@ -13,6 +13,7 @@ from app.models import Contact
from app.radio import RadioManager, radio_manager from app.radio import RadioManager, radio_manager
from app.routers.radio import ( from app.routers.radio import (
PrivateKeyUpdate, PrivateKeyUpdate,
RadioAdvertiseRequest,
RadioConfigResponse, RadioConfigResponse,
RadioConfigUpdate, RadioConfigUpdate,
RadioDiscoveryRequest, RadioDiscoveryRequest,
@@ -598,14 +599,51 @@ class TestAdvertise:
assert exc.value.status_code == 500 assert exc.value.status_code == 500
@pytest.mark.asyncio
async def test_defaults_to_flood_mode(self):
radio_manager._meshcore = MagicMock()
with (
patch("app.routers.radio.require_connected"),
patch(
"app.routers.radio.do_send_advertisement",
new_callable=AsyncMock,
return_value=True,
) as mock_send,
):
result = await send_advertisement()
assert result == {"status": "ok"}
mock_send.assert_awaited_once()
assert mock_send.await_args.kwargs["force"] is True
assert mock_send.await_args.kwargs["mode"] == "flood"
@pytest.mark.asyncio
async def test_accepts_zero_hop_mode(self):
radio_manager._meshcore = MagicMock()
with (
patch("app.routers.radio.require_connected"),
patch(
"app.routers.radio.do_send_advertisement",
new_callable=AsyncMock,
return_value=True,
) as mock_send,
):
result = await send_advertisement(RadioAdvertiseRequest(mode="zero_hop"))
assert result == {"status": "ok"}
mock_send.assert_awaited_once()
assert mock_send.await_args.kwargs["force"] is True
assert mock_send.await_args.kwargs["mode"] == "zero_hop"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_concurrent_advertise_calls_are_serialized(self): async def test_concurrent_advertise_calls_are_serialized(self):
active = 0 active = 0
max_active = 0 max_active = 0
async def fake_send(mc, *, force: bool): async def fake_send(mc, *, force: bool, mode: str):
nonlocal active, max_active nonlocal active, max_active
assert force is True assert force is True
assert mode == "flood"
active += 1 active += 1
max_active = max(max_active, active) max_active = max(max_active, active)
await asyncio.sleep(0.05) await asyncio.sleep(0.05)