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
+3 -1
View File
@@ -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<RadioDiscoveryResponse>('/radio/discover', {
+2 -1
View File
@@ -3,6 +3,7 @@ import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -35,7 +36,7 @@ interface SettingsModalBaseProps {
onReboot: () => Promise<void>;
onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>;
onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -11,6 +11,7 @@ import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -45,7 +46,7 @@ export function SettingsRadioSection({
onReboot: () => Promise<void>;
onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>;
onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -82,7 +83,7 @@ export function SettingsRadioSection({
const [floodError, setFloodError] = useState<string | null>(null);
// Advertise state
const [advertising, setAdvertising] = useState(false);
const [advertisingMode, setAdvertisingMode] = useState<RadioAdvertMode | null>(null);
const [discoverError, setDiscoverError] = useState<string | null>(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({
<div className="space-y-2">
<Label>Send Advertisement</Label>
<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>
<Button
onClick={handleAdvertise}
disabled={advertising || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertising ? 'Sending...' : 'Send Advertisement'}
</Button>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
onClick={() => handleAdvertise('flood')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{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 && (
<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 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',
});
}
+17 -2
View File
@@ -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' }));
});
});
+21 -1
View File
@@ -6,6 +6,7 @@ import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -74,6 +75,7 @@ function renderModal(overrides?: {
onReboot?: () => Promise<void>;
onDisconnect?: () => Promise<void>;
onReconnect?: () => Promise<void>;
onAdvertise?: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery?: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -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 });
+2
View File
@@ -45,6 +45,8 @@ export interface RadioDiscoveryResponse {
results: RadioDiscoveryResult[];
}
export type RadioAdvertMode = 'flood' | 'zero_hop';
export interface FanoutStatusEntry {
name: string;
type: string;