From b832239e22386060add03bbdfe61114507a35d3d Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 18 Mar 2026 19:59:08 -0700 Subject: [PATCH] Add zero-hop impulse advert. Closes #83. --- AGENTS.md | 2 +- app/AGENTS.md | 2 +- app/radio_sync.py | 33 ++++++++++----- app/routers/radio.py | 25 ++++++++---- frontend/AGENTS.md | 1 + frontend/src/api.ts | 4 +- frontend/src/components/SettingsModal.tsx | 3 +- .../settings/SettingsRadioSection.tsx | 39 +++++++++++------- frontend/src/hooks/useRadioControl.ts | 12 +++--- frontend/src/test/api.test.ts | 19 ++++++++- frontend/src/test/settingsModal.test.tsx | 22 +++++++++- frontend/src/types.ts | 2 + tests/test_radio_router.py | 40 ++++++++++++++++++- 13 files changed, 159 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d39694..72812dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | | 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 | -| 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/reboot` | Reboot radio or reconnect if disconnected | | POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts | diff --git a/app/AGENTS.md b/app/AGENTS.md index 276bd97..eb741c5 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -168,7 +168,7 @@ app/ - `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 - `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/disconnect` - `POST /radio/reboot` diff --git a/app/radio_sync.py b/app/radio_sync.py index 40ff4b1..9d82499 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -14,6 +14,7 @@ import logging import math import time from contextlib import asynccontextmanager +from typing import Literal from meshcore import EventType, MeshCore @@ -37,6 +38,8 @@ logger = logging.getLogger(__name__) DEFAULT_MAX_CHANNELS = 40 +AdvertMode = Literal["flood", "zero_hop"] + def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]: """Return key contact fields for sync failure diagnostics.""" @@ -686,7 +689,12 @@ async def stop_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. 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: mc: The MeshCore instance to use for the advertisement. 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). """ - # Check if enough time has elapsed (unless forced) - if not force: + use_flood = mode == "flood" + + # Only flood adverts currently participate in persisted throttle state. + if use_flood and not force: settings = await AppSettingsRepository.get() interval = settings.advert_interval last_time = settings.last_advert_time @@ -726,18 +738,19 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool: return False try: - result = await mc.commands.send_advert(flood=True) + result = await mc.commands.send_advert(flood=use_flood) if result.type == EventType.OK: - # Update last_advert_time in database - now = int(time.time()) - await AppSettingsRepository.update(last_advert_time=now) - logger.info("Advertisement sent successfully") + if use_flood: + # Track flood advert timing for periodic/startup throttling. + now = int(time.time()) + await AppSettingsRepository.update(last_advert_time=now) + logger.info("%s advertisement sent successfully", mode.replace("_", "-")) return True else: - logger.warning("Failed to send advertisement: %s", result.payload) + logger.warning("Failed to send %s advertisement: %s", mode, result.payload) return False 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 diff --git a/app/routers/radio.py b/app/routers/radio.py index 8488872..7c7b594 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -32,6 +32,7 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/radio", tags=["radio"]) AdvertLocationSource = Literal["off", "current"] +RadioAdvertMode = Literal["flood", "zero_hop"] DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"] DISCOVERY_WINDOW_SECONDS = 8.0 _DISCOVERY_TARGET_BITS = { @@ -104,6 +105,13 @@ class PrivateKeyUpdate(BaseModel): 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: return time.monotonic() @@ -266,24 +274,25 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict: @router.post("/advertise") -async def send_advertisement() -> dict: - """Send a flood advertisement to announce presence on the mesh. +async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> dict: + """Send an advertisement to announce presence on the mesh. - Manual advertisement requests always send immediately, updating the - last_advert_time which affects when the next periodic/startup advert - can occur. + Manual advertisement requests always send immediately. Flood adverts update + the shared flood-advert timing state used by periodic/startup advertising; + zero-hop adverts currently do not. Returns: status: "ok" if sent successfully """ 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: - success = await do_send_advertisement(mc, force=True) + success = await do_send_advertisement(mc, force=True, mode=mode) 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"} diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index f32c1b3..d96d0c6 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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. - 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. - Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fede7ae..bd98c49 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,6 +15,7 @@ import type { MessagesAroundResponse, MigratePreferencesRequest, MigratePreferencesResponse, + RadioAdvertMode, RadioConfig, RadioConfigUpdate, RadioDiscoveryResponse, @@ -95,9 +96,10 @@ export const api = { method: 'PUT', body: JSON.stringify({ private_key: privateKey }), }), - sendAdvertisement: () => + sendAdvertisement: (mode: RadioAdvertMode = 'flood') => fetchJson<{ status: string }>('/radio/advertise', { method: 'POST', + body: JSON.stringify({ mode }), }), discoverMesh: (target: RadioDiscoveryTarget) => fetchJson('/radio/discover', { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 96cb9f5..eaa77ad 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3,6 +3,7 @@ import type { AppSettings, AppSettingsUpdate, HealthStatus, + RadioAdvertMode, RadioConfig, RadioConfigUpdate, RadioDiscoveryResponse, @@ -35,7 +36,7 @@ interface SettingsModalBaseProps { onReboot: () => Promise; onDisconnect: () => Promise; onReconnect: () => Promise; - onAdvertise: () => Promise; + onAdvertise: (mode: RadioAdvertMode) => Promise; meshDiscovery: RadioDiscoveryResponse | null; meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null; onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise; diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index 2869c5c..d562dad 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -11,6 +11,7 @@ import type { AppSettings, AppSettingsUpdate, HealthStatus, + RadioAdvertMode, RadioConfig, RadioConfigUpdate, RadioDiscoveryResponse, @@ -45,7 +46,7 @@ export function SettingsRadioSection({ onReboot: () => Promise; onDisconnect: () => Promise; onReconnect: () => Promise; - onAdvertise: () => Promise; + onAdvertise: (mode: RadioAdvertMode) => Promise; meshDiscovery: RadioDiscoveryResponse | null; meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null; onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise; @@ -82,7 +83,7 @@ export function SettingsRadioSection({ const [floodError, setFloodError] = useState(null); // Advertise state - const [advertising, setAdvertising] = useState(false); + const [advertisingMode, setAdvertisingMode] = useState(null); const [discoverError, setDiscoverError] = useState(null); const [connectionBusy, setConnectionBusy] = useState(false); @@ -295,12 +296,12 @@ export function SettingsRadioSection({ } }; - const handleAdvertise = async () => { - setAdvertising(true); + const handleAdvertise = async (mode: RadioAdvertMode) => { + setAdvertisingMode(mode); try { - await onAdvertise(); + await onAdvertise(mode); } finally { - setAdvertising(false); + setAdvertisingMode(null); } }; @@ -742,15 +743,25 @@ export function SettingsRadioSection({

- 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.

- +
+ + +
{!health?.radio_connected && (

Radio not connected

)} diff --git a/frontend/src/hooks/useRadioControl.ts b/frontend/src/hooks/useRadioControl.ts index 3a2b06c..d1243fb 100644 --- a/frontend/src/hooks/useRadioControl.ts +++ b/frontend/src/hooks/useRadioControl.ts @@ -4,6 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch'; import { toast } from '../components/ui/sonner'; import type { HealthStatus, + RadioAdvertMode, RadioConfig, RadioConfigUpdate, RadioDiscoveryResponse, @@ -93,13 +94,14 @@ export function useRadioControl() { } }, [fetchConfig]); - const handleAdvertise = useCallback(async () => { + const handleAdvertise = useCallback(async (mode: RadioAdvertMode = 'flood') => { try { - await api.sendAdvertisement(); - toast.success('Advertisement sent'); + await api.sendAdvertisement(mode); + toast.success(mode === 'zero_hop' ? 'Zero-hop advertisement sent' : 'Advertisement sent'); } catch (err) { - console.error('Failed to send advertisement:', err); - toast.error('Failed to send advertisement', { + const label = mode === 'zero_hop' ? 'zero-hop advertisement' : 'advertisement'; + console.error(`Failed to send ${label}:`, err); + toast.error(`Failed to send ${label}`, { description: err instanceof Error ? err.message : 'Check radio connection', }); } diff --git a/frontend/src/test/api.test.ts b/frontend/src/test/api.test.ts index 0f54200..2007773 100644 --- a/frontend/src/test/api.test.ts +++ b/frontend/src/test/api.test.ts @@ -305,7 +305,7 @@ describe('fetchJson (via api methods)', () => { expect(options.method).toBe('DELETE'); }); - it('sends POST without body for sendAdvertisement', async () => { + it('sends POST with flood mode for sendAdvertisement', async () => { installMockFetch(); mockFetch.mockResolvedValueOnce({ ok: true, @@ -317,7 +317,22 @@ describe('fetchJson (via api methods)', () => { const [url, options] = mockFetch.mock.calls[0]; expect(url).toBe('/api/radio/advertise'); 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' })); }); }); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 450f33a..30f2b21 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -6,6 +6,7 @@ import type { AppSettings, AppSettingsUpdate, HealthStatus, + RadioAdvertMode, RadioConfig, RadioConfigUpdate, RadioDiscoveryResponse, @@ -74,6 +75,7 @@ function renderModal(overrides?: { onReboot?: () => Promise; onDisconnect?: () => Promise; onReconnect?: () => Promise; + onAdvertise?: (mode: RadioAdvertMode) => Promise; meshDiscovery?: RadioDiscoveryResponse | null; meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null; onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise; @@ -93,6 +95,7 @@ function renderModal(overrides?: { const onReboot = overrides?.onReboot ?? vi.fn(async () => {}); const onDisconnect = overrides?.onDisconnect ?? 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 commonProps = { @@ -108,7 +111,7 @@ function renderModal(overrides?: { onReboot, onDisconnect, onReconnect, - onAdvertise: vi.fn(async () => {}), + onAdvertise, meshDiscovery: overrides?.meshDiscovery ?? null, meshDiscoveryLoadingTarget: overrides?.meshDiscoveryLoadingTarget ?? null, onDiscoverMesh, @@ -135,6 +138,7 @@ function renderModal(overrides?: { onReboot, onDisconnect, onReconnect, + onAdvertise, onDiscoverMesh, view, }; @@ -206,6 +210,22 @@ describe('SettingsModal', () => { 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', () => { renderModal({ config: null }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a2a929c..2445d52 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -45,6 +45,8 @@ export interface RadioDiscoveryResponse { results: RadioDiscoveryResult[]; } +export type RadioAdvertMode = 'flood' | 'zero_hop'; + export interface FanoutStatusEntry { name: string; type: string; diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index 10a2afd..caa0aa5 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -13,6 +13,7 @@ from app.models import Contact from app.radio import RadioManager, radio_manager from app.routers.radio import ( PrivateKeyUpdate, + RadioAdvertiseRequest, RadioConfigResponse, RadioConfigUpdate, RadioDiscoveryRequest, @@ -598,14 +599,51 @@ class TestAdvertise: 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 async def test_concurrent_advertise_calls_are_serialized(self): 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 assert force is True + assert mode == "flood" active += 1 max_active = max(max_active, active) await asyncio.sleep(0.05)